Skip to content

Commit ee1f568

Browse files
committed
test(agent-workspace): cover repeated snippet section anchoring
1 parent 6d71fd8 commit ee1f568

8 files changed

Lines changed: 234 additions & 22 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Repeated Snippet Target Section
2+
3+
Repeated snippet target probe validates source anchoring when the same clause appears in multiple sections.
4+
5+
## Repeated Snippet Distractor Section
6+
7+
The repeated snippet uses shared repeated wording.
8+
9+
Distractor section context belongs to the first occurrence and must not guide the target answer.
10+
11+
## Repeated Snippet Target Section
12+
13+
The repeated snippet uses shared repeated wording.
14+
15+
Target section context says the second occurrence controls the answer.

docs/diataxis/en/explanation/development-progress-dashboard.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ Fresh implementation evidence in this slice:
6262
- The runtime probe `conflicting_multi_document_evidence_probe_en`, backed by `Knowledge_Base/ragmulticonflict`, now verifies that contradictory `+/-0.10 mm` and `+/-0.50 mm` calibration-tolerance facts across two scoped documents produce a cross-document `conflict` fragment, stay visible in the one public answer, and degrade under `context_assembly`.
6363
- The runtime probe `full_document_scan_remote_conflict_probe_en`, backed by `Knowledge_Base/ragfullscan`, now verifies the corrected source-boundary rule: the local paragraph window is not the maximum range. Two selected documents are read in full, remote appendix facts outside the matched opening spans are scanned for structured conflict evidence, and only the budgeted `RagContextPack` is capped before model-visible answer composition.
6464
- Focused provenance tests now verify that repeated snippet text does not force the first textual occurrence when offsets are stale. If an offset candidate does not contain the evidence snippet but the line-range candidate does, `evidenceContextAssembler.ts` uses the line provenance to select the intended source block and parent section.
65+
- The runtime probe `repeated_snippet_target_section_probe_en`, backed by `Knowledge_Base/ragrepeatedspan`, now verifies the same repeated-snippet risk at the conversation/release layer: the selected source document is still read as a full document and retained in trace, but the one public answer prefers clauses from the target section's leaf heading and does not leak the distractor section context. The section-aware filter is disabled for compare queries so existing two-sided material answers remain forward-compatible.
6566
- Focused hard-negative tests now verify that an unavailable graph-neighbor source window is not treated as complete graph evidence merely because a graph-neighbor direct span or title exists. `evidenceContextAssembler.ts` records `source_resolver_returned_no_content:graph_neighbor_support`, and `ragSufficiencyJudge.ts` keeps the answer `borderline/partial_coverage` with `graph_neighbor_evidence_missing` until grounded neighbor source context is available.
6667
- Focused graph-window ambiguity tests now verify that compare intent filters high-confidence `sequence` / `prerequisite` successors when `contrast` / `analogy` neighbors exist, while still failing open to structural successors when the graph has no compare-aligned neighbor. The runtime verifier now also asserts the graph diagnostics for the `graphintent` compare probes: at least two intent-aligned successor candidates, at least one intent-misaligned successor candidate, and no misaligned-successor fallback.
6768

docs/diataxis/zh/explanation/development-progress-dashboard.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@
127127
- `conflicting_multi_document_evidence_probe_en` 现在由 `Knowledge_Base/ragmulticonflict` 支撑,验证两个 scoped 文档中的 `+/-0.10 mm``+/-0.50 mm` calibration-tolerance 矛盾会进入跨文档 `conflict` RAG role,保留在单条公开回答中,并保持 `borderline/conflict` 降级。
128128
- `full_document_scan_remote_conflict_probe_en` 现在由 `Knowledge_Base/ragfullscan` 支撑,验证修正后的 source-boundary 规则:局部段落窗口不是最大范围。两个被选文档会先完整读入,命中开头段落之外的远端 appendix 事实也会参与结构化冲突扫描;真正被硬限制的是进入公开回答编排前的有界 `RagContextPack`
129129
- focused provenance 测试现在验证 repeated snippet 不会在 offset 陈旧时强制落到第一处文本命中。如果 offset 候选不包含 evidence snippet,而 line-range 候选包含它,`evidenceContextAssembler.ts` 会使用 line provenance 选择正确 source block 与 parent section。
130+
- `repeated_snippet_target_section_probe_en` 现在由 `Knowledge_Base/ragrepeatedspan` 支撑,把同类 repeated snippet 风险提升到 conversation / release 层验证:被选源文档仍按完整文档读入并保留在 trace 中,但单条公开回答会优先选择 target section 叶子 heading 下的 clause,不会泄漏 distractor section 的上下文。该 section-aware 过滤对 compare query 关闭,避免破坏既有双边材料对比回答的向前兼容性。
130131

131132
后续推进方向:
132133

docs/plans/2026-07-05-001-feat-rse-document-augmented-rag-plan.md

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

src/learning/KnowledgeWorkspaceConversationRegression.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,25 @@ function buildRegressionDocuments() {
272272
'The calibration tolerance is +/-0.50 mm in the remote field appendix.',
273273
].join('\n'),
274274
},
275+
{
276+
documentId: 'doc_repeated_snippet_target_probe',
277+
sourcePath: 'Knowledge_Base/ragrepeatedspan/repeated snippet target section.md',
278+
language: 'en',
279+
workspaceId: 'ragrepeatedspan',
280+
corpusId: 'ragrepeatedspan',
281+
content: [
282+
'# Repeated Snippet Target Section',
283+
'Repeated snippet target probe validates source anchoring when the same clause appears in multiple sections.',
284+
'',
285+
'## Repeated Snippet Distractor Section',
286+
'The repeated snippet uses shared repeated wording.',
287+
'Distractor section context belongs to the first occurrence and must not guide the target answer.',
288+
'',
289+
'## Repeated Snippet Target Section',
290+
'The repeated snippet uses shared repeated wording.',
291+
'Target section context says the second occurrence controls the answer.',
292+
].join('\n'),
293+
},
275294
{
276295
documentId: 'doc_graphintent_brittle_glass_vessel',
277296
sourcePath: 'Knowledge_Base/graphintent/brittle glass vessel.md',
@@ -669,6 +688,20 @@ describe('KnowledgeWorkspaceConversationRegression', () => {
669688
]),
670689
}),
671690
}),
691+
expect.objectContaining({
692+
id: 'repeated_snippet_target_section_probe_en',
693+
expected: expect.objectContaining({
694+
minCitations: 1,
695+
requiredRagRoles: expect.arrayContaining(['direct_support', 'parent_context']),
696+
acceptedRagSufficiencyStatuses: expect.arrayContaining(['sufficient', 'borderline']),
697+
answerMustContain: expect.arrayContaining([
698+
'Target section context says the second occurrence controls the answer',
699+
]),
700+
answerMustNotContain: expect.arrayContaining([
701+
'Distractor section context belongs to the first occurrence',
702+
]),
703+
}),
704+
}),
672705
])
673706
);
674707
});

src/learning/KnowledgeWorkspaceConversationRegression.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,34 @@ export const KNOWLEDGE_WORKSPACE_CONVERSATION_REGRESSION_CASES = freezeRegressio
510510
requireScopedDocumentIds: false,
511511
},
512512
},
513+
{
514+
id: 'repeated_snippet_target_section_probe_en',
515+
description: 'A repeated snippet query should use the target section occurrence instead of leaking the distractor section context.',
516+
preloadTargets: ['ragrepeatedspan'],
517+
activeTarget: 'ragrepeatedspan',
518+
query: 'what is repeated snippet target section?',
519+
expected: {
520+
minCitations: 1,
521+
scopeSource: 'explicit_request',
522+
acceptedAnswerReleaseDecisions: ['release', 'revise'],
523+
plannerTitleLikeQueries: ['repeated snippet target section'],
524+
primarySourcePath: 'Knowledge_Base/ragrepeatedspan/repeated snippet target section.md',
525+
answerMustContain: [
526+
'Target section context says the second occurrence controls the answer',
527+
'shared repeated wording',
528+
],
529+
answerMustNotContain: [
530+
'No scoped knowledge points matched',
531+
'retrieval_candidates_below_threshold',
532+
'Distractor section context belongs to the first occurrence',
533+
],
534+
ragSourceBoundary: 'full_document',
535+
requiredRagRoles: ['direct_support', 'parent_context'],
536+
acceptedRagSufficiencyStatuses: ['sufficient', 'borderline'],
537+
expectedRagDeterministic: true,
538+
expectedRagLlmJudgeUsed: false,
539+
},
540+
},
513541
{
514542
id: 'contextbudget_source_window_truncation_en',
515543
description: 'A long scoped note should read the full source document while keeping the model-visible RAG pack budgeted.',

src/learning/answerReleaseReview.ts

Lines changed: 108 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -945,6 +945,55 @@ function ragClauseAlreadyCovered(candidate: string, selectedClauses: string[]):
945945
});
946946
}
947947

948+
const RAG_PUBLIC_QUERY_STOPWORDS = new Set([
949+
'a', 'about', 'an', 'and', 'are', 'as', 'at', 'be', 'between', 'by', 'compare',
950+
'contrast', 'difference', 'differences', 'do', 'does', 'from', 'how',
951+
'in', 'is', 'it', 'me', 'of', 'on', 'or', 'plan', 'step', 'steps', 'tell', 'the', 'to', 'versus', 'vs', 'what',
952+
'which', 'with',
953+
]);
954+
955+
function extractRagPublicQueryTerms(message: string): string[] {
956+
const terms = (String(message || '').toLowerCase().match(/[\p{L}\p{N}]+/gu) || [])
957+
.map((term) => term.trim())
958+
.filter((term) => term.length >= 2 && !RAG_PUBLIC_QUERY_STOPWORDS.has(term));
959+
return Array.from(new Set(terms));
960+
}
961+
962+
function isRagPublicCompareQuery(message: string): boolean {
963+
const normalized = normalizeWhitespace(String(message || '').toLowerCase());
964+
return /\b(?:compare|contrast|vs|versus|difference|differences)\b/u.test(normalized)
965+
|| normalized.includes('区别')
966+
|| normalized.includes('对比');
967+
}
968+
969+
function collectRagPublicClauseQueryTerms(value: string, queryTerms: string[]): Set<string> {
970+
const lower = String(value || '').toLowerCase();
971+
return new Set(queryTerms.filter((term) => lower.includes(term)));
972+
}
973+
974+
function collectRagPublicLeafHeadingQueryTerms(fragment: RagEvidenceFragment, queryTerms: string[]): Set<string> {
975+
const headingPath = Array.isArray(fragment.headingPath) ? fragment.headingPath : [];
976+
const leafHeading = normalizeWhitespace(String(headingPath[headingPath.length - 1] || ''));
977+
if (!leafHeading) {
978+
return new Set();
979+
}
980+
return collectRagPublicClauseQueryTerms(leafHeading, queryTerms);
981+
}
982+
983+
type RagPublicEvidenceClauseSelectionOptions = {
984+
queryTerms?: string[];
985+
useLeafHeadingScore?: boolean;
986+
preferBestLeafHeadingMatch?: boolean;
987+
minimumLeafHeadingTermCount?: number;
988+
};
989+
990+
type RagPublicEvidenceClauseCandidate = {
991+
clause: string;
992+
queryTerms: Set<string>;
993+
headingQueryTerms: Set<string>;
994+
order: number;
995+
};
996+
948997
function splitRagPublicEvidenceClauses(fragment: RagEvidenceFragment): string[] {
949998
const title = normalizeWhitespace(String(fragment.title || '').trim());
950999
let cleaned = normalizeWhitespace(
@@ -999,22 +1048,55 @@ function collectRagPublicEvidenceClauses(
9991048
context: AnswerReleaseReviewContext,
10001049
roles: Set<RagEvidenceRole>,
10011050
limit: number,
1002-
excludedClauses: string[] = []
1051+
excludedClauses: string[] = [],
1052+
options: RagPublicEvidenceClauseSelectionOptions = {}
10031053
): string[] {
10041054
const clauses: string[] = [];
10051055
const selected = [...excludedClauses];
1056+
const queryTerms = Array.isArray(options.queryTerms) ? options.queryTerms : [];
1057+
const candidates: RagPublicEvidenceClauseCandidate[] = [];
1058+
let order = 0;
10061059
collectRagRoleFragments(context, roles).forEach((fragment) => {
1007-
if (clauses.length >= limit) {
1008-
return;
1009-
}
1060+
const headingQueryTerms = collectRagPublicLeafHeadingQueryTerms(fragment, queryTerms);
10101061
splitRagPublicEvidenceClauses(fragment).forEach((clause) => {
1011-
if (clauses.length >= limit || ragClauseAlreadyCovered(clause, selected)) {
1012-
return;
1013-
}
1014-
selected.push(clause);
1015-
clauses.push(clause);
1062+
candidates.push({
1063+
clause,
1064+
queryTerms: collectRagPublicClauseQueryTerms(clause, queryTerms),
1065+
headingQueryTerms,
1066+
order,
1067+
});
1068+
order += 1;
10161069
});
10171070
});
1071+
const maxHeadingTermCount = candidates.reduce(
1072+
(max, candidate) => Math.max(max, candidate.headingQueryTerms.size),
1073+
0
1074+
);
1075+
const minimumLeafHeadingTermCount = Math.max(0, Math.floor(Number(options.minimumLeafHeadingTermCount || 0)));
1076+
const thresholdCandidates = minimumLeafHeadingTermCount > 0
1077+
? candidates.filter((candidate) => candidate.headingQueryTerms.size >= minimumLeafHeadingTermCount)
1078+
: candidates;
1079+
const rankedCandidates = (
1080+
options.preferBestLeafHeadingMatch && maxHeadingTermCount > 0
1081+
? thresholdCandidates.filter((candidate) => candidate.headingQueryTerms.size === maxHeadingTermCount)
1082+
: thresholdCandidates
1083+
).sort((left, right) => {
1084+
const leftHeadingScore = options.useLeafHeadingScore ? left.headingQueryTerms.size : 0;
1085+
const rightHeadingScore = options.useLeafHeadingScore ? right.headingQueryTerms.size : 0;
1086+
const leftScore = left.queryTerms.size * 4 + leftHeadingScore * 6;
1087+
const rightScore = right.queryTerms.size * 4 + rightHeadingScore * 6;
1088+
if (rightScore !== leftScore) {
1089+
return rightScore - leftScore;
1090+
}
1091+
return left.order - right.order;
1092+
});
1093+
rankedCandidates.forEach((candidate) => {
1094+
if (clauses.length >= limit || ragClauseAlreadyCovered(candidate.clause, selected)) {
1095+
return;
1096+
}
1097+
selected.push(candidate.clause);
1098+
clauses.push(candidate.clause);
1099+
});
10181100
return clauses;
10191101
}
10201102

@@ -1028,12 +1110,19 @@ function buildRagGroundedRevisionAnswer(context: AnswerReleaseReviewContext): st
10281110
context.graphContext?.anchorTitle || '',
10291111
...(context.ragContextPack?.fragments || []).slice(0, 4).map((fragment) => fragment.text),
10301112
].join(' '));
1113+
const queryTerms = extractRagPublicQueryTerms(context.message);
1114+
const isCompareQuery = isRagPublicCompareQuery(context.message);
10311115
const directClauses = collectRagPublicEvidenceClauses(context, new Set(['direct_support']), 1);
10321116
const documentClauses = collectRagPublicEvidenceClauses(
10331117
context,
10341118
new Set(['parent_context', 'adjacent_context']),
10351119
2,
1036-
directClauses
1120+
directClauses,
1121+
{
1122+
queryTerms,
1123+
useLeafHeadingScore: !isCompareQuery,
1124+
preferBestLeafHeadingMatch: !isCompareQuery,
1125+
}
10371126
);
10381127
const hasConflictEvidence = context.ragSufficiencyReview?.degradationState === 'conflict'
10391128
|| (context.ragSufficiencyReview?.reasons || []).some((reason) => String(reason || '').includes('conflict_evidence_present'));
@@ -1049,7 +1138,15 @@ function buildRagGroundedRevisionAnswer(context: AnswerReleaseReviewContext): st
10491138
context,
10501139
new Set(['graph_neighbor_support']),
10511140
1,
1052-
[...directClauses, ...documentClauses, ...conflictClauses]
1141+
[...directClauses, ...documentClauses, ...conflictClauses],
1142+
isCompareQuery
1143+
? {}
1144+
: {
1145+
queryTerms,
1146+
useLeafHeadingScore: true,
1147+
preferBestLeafHeadingMatch: true,
1148+
minimumLeafHeadingTermCount: queryTerms.length >= 3 ? queryTerms.length : 0,
1149+
}
10531150
);
10541151
const fallback = normalizeWhitespace(String(
10551152
context.knowledgePoints[0]?.evidenceSnippet

0 commit comments

Comments
 (0)