Assessor_2.0/server/regulatory/regulatory-finding-retrieva...

133 lines
5.5 KiB
TypeScript

import type {
RegulatoryFindingQuery,
RegulatoryRetrievalCandidate,
RegulatoryRetrievalFilters,
RegulatoryRetriever
} from '../../src/domain/regulatory-rag';
import type { UnifiedResultProjection } from '../../src/domain/unified-result';
import { buildRegulatoryFindingQuery } from '../../src/profiles/public-search/regulatory-query';
const RECIPROCAL_RANK_FUSION_K = 60;
const FINDING_QUERY_SEARCH_LIMIT = 64;
const SECTION_SEARCH_LIMIT = 32;
const FULL_CONTEXT_CONTROL_LIMIT = 100;
const CANDIDATE_SECTION_KINDS = ['control', 'purpose', 'guidance'] as const;
export interface RegulatoryFindingRetrievalResult {
sourceResultId: string;
status: 'candidates' | 'insufficient-public-context';
preparedQuery?: RegulatoryFindingQuery;
candidates: RegulatoryRetrievalCandidate[];
}
export class RegulatoryFindingRetrievalService {
constructor(private readonly retriever: RegulatoryRetriever) {}
async retrieve(
finding: UnifiedResultProjection,
limit: number,
filters?: RegulatoryRetrievalFilters
): Promise<RegulatoryFindingRetrievalResult> {
const preparedQuery = buildRegulatoryFindingQuery(finding);
if (!preparedQuery) {
return { sourceResultId: finding.sourceResultId, status: 'insufficient-public-context', candidates: [] };
}
const query = preparedQuery.retrievalQuery ?? preparedQuery.query;
if (filters?.sectionKind) {
const candidates = await this.retriever.retrieve({ query, limit, filters });
return { sourceResultId: finding.sourceResultId, status: 'candidates', preparedQuery, candidates };
}
const selectionQueries = [...new Set([
preparedQuery.problemQuery ?? query,
preparedQuery.remediationQuery
].filter((value): value is string => Boolean(value)))];
const selectionRankings = await Promise.all(selectionQueries.map(selectionQuery => this.retriever.retrieve({
query: selectionQuery,
limit: Math.max(limit, FINDING_QUERY_SEARCH_LIMIT),
filters
})));
const sectionRankings = await Promise.all(CANDIDATE_SECTION_KINDS.map(sectionKind => this.retriever.retrieve({
query,
limit: Math.max(limit, SECTION_SEARCH_LIMIT),
filters: { ...filters, sectionKind }
})));
const candidates = mergeFindingQueryRankings(selectionRankings, sectionRankings, limit);
return { sourceResultId: finding.sourceResultId, status: 'candidates', preparedQuery, candidates };
}
}
/**
* Collapses the full-context section searches into one control ranking, then
* fuses that family with the independently phrased problem and remediation
* rankings. A control can therefore receive at most one final vote from the
* section family while its section coverage still determines that family's
* internal order.
*/
export function mergeFindingQueryRankings(
selectionRankings: RegulatoryRetrievalCandidate[][],
evidenceRankings: RegulatoryRetrievalCandidate[][],
limit: number
): RegulatoryRetrievalCandidate[] {
if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new Error('Regulatory retrieval limit must be between 1 and 100.');
const fullContextRanking = reciprocalRankFuseByControl(evidenceRankings, FULL_CONTEXT_CONTROL_LIMIT);
const selected = uniqueControlIds(reciprocalRankFuseByControl(
[...selectionRankings, fullContextRanking],
limit
));
const allEvidenceRankings = [...evidenceRankings, ...selectionRankings];
return selected.flatMap((controlId, index) => {
const seenChunks = new Set<string>();
return allEvidenceRankings.flatMap(ranking => {
const hit = ranking.find(candidate => candidate.chunk.controlId === controlId && !seenChunks.has(candidate.chunk.chunkId));
if (!hit) return [];
seenChunks.add(hit.chunk.chunkId);
return [{ ...hit, score: 1 / (RECIPROCAL_RANK_FUSION_K + index + 1) }];
});
});
}
/** Fuses independent rankings at control level, allowing one vote per control and ranking. */
export function reciprocalRankFuseByControl(
rankings: RegulatoryRetrievalCandidate[][],
limit: number
): RegulatoryRetrievalCandidate[] {
if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new Error('Regulatory retrieval limit must be between 1 and 100.');
const controls = new Map<string, {
score: number;
bestRank: number;
hits: Map<number, RegulatoryRetrievalCandidate>;
}>();
rankings.forEach((ranking, rankingIndex) => {
const seen = new Set<string>();
for (const hit of ranking) {
const controlId = hit.chunk.controlId;
if (seen.has(controlId)) continue;
seen.add(controlId);
const controlRank = seen.size;
const entry = controls.get(controlId) ?? { score: 0, bestRank: Number.MAX_SAFE_INTEGER, hits: new Map() };
entry.score += 1 / (RECIPROCAL_RANK_FUSION_K + controlRank);
entry.bestRank = Math.min(entry.bestRank, controlRank);
entry.hits.set(rankingIndex, hit);
controls.set(controlId, entry);
}
});
return [...controls.entries()]
.sort(([leftId, left], [rightId, right]) => right.score - left.score
|| left.bestRank - right.bestRank
|| leftId.localeCompare(rightId, 'en', { numeric: true }))
.slice(0, limit)
.flatMap(([, entry]) => [...entry.hits.entries()]
.sort(([left], [right]) => left - right)
.map(([, hit]) => ({ ...hit, score: entry.score })));
}
function uniqueControlIds(candidates: RegulatoryRetrievalCandidate[]): string[] {
const seen = new Set<string>();
return candidates.flatMap(candidate => {
const controlId = candidate.chunk.controlId;
if (seen.has(controlId)) return [];
seen.add(controlId);
return [controlId];
});
}