79620bccaa
Vision reports a display name ("Karti") while the git watcher reports a handle ("karti"); the detectors compared engineerId case-sensitively, so the same human collided with themselves. Canonicalize identity at the decision point in both the file and research detectors, keeping the display-cased name for the card.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
148 lines
4.6 KiB
TypeScript
148 lines
4.6 KiB
TypeScript
import type { Collision, EngineerContext } from '@podman/shared';
|
|
import { env } from '../env.js';
|
|
import type { GitState } from '../memory/db.js';
|
|
import { semanticSimilarity } from '../memory/vectors.js';
|
|
|
|
export interface ResearchOpts {
|
|
similarity?: (a: string, b: string) => Promise<number | null>;
|
|
threshold?: number;
|
|
}
|
|
|
|
interface EditorFile {
|
|
engineerId: string;
|
|
file: string;
|
|
symbol?: string;
|
|
activity?: string;
|
|
}
|
|
|
|
interface Candidate {
|
|
collision: Collision;
|
|
score: number;
|
|
}
|
|
|
|
function stripGitPrefix(raw: string): string {
|
|
return raw.trim().replace(/^(\?\?|[MADRCU!]{1,2})\s+/, '');
|
|
}
|
|
|
|
/** Case/whitespace-insensitive identity so vision "Karti" and git "karti" are
|
|
* recognized as the same person and never flagged researching-vs-editing self. */
|
|
function canonicalName(raw: string): string {
|
|
return raw.trim().toLowerCase();
|
|
}
|
|
|
|
function fileStem(raw: string): string {
|
|
const base = stripGitPrefix(raw).split(/[\\/]/).pop()?.trim().toLowerCase() ?? '';
|
|
return base.replace(/\.[^.]+$/, '');
|
|
}
|
|
|
|
function words(raw: string): string[] {
|
|
return raw
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, ' ')
|
|
.split(/\s+/)
|
|
.filter((word) => word.length >= 3);
|
|
}
|
|
|
|
function uniqueTokens(raw: string): Set<string> {
|
|
const tokens = new Set(words(raw));
|
|
for (const word of [...tokens]) {
|
|
if (word.endsWith('kit')) tokens.add(word.replace(/kit$/, ''));
|
|
if (word.endsWith('s')) tokens.add(word.slice(0, -1));
|
|
}
|
|
return tokens;
|
|
}
|
|
|
|
function fallbackMatches(researchText: string, fileText: string): boolean {
|
|
const research = uniqueTokens(researchText);
|
|
const file = uniqueTokens(fileText);
|
|
for (const token of file) {
|
|
if (research.has(token)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function collectEditorFiles(
|
|
contexts: EngineerContext[],
|
|
gitStates: Map<string, GitState> | undefined,
|
|
): EditorFile[] {
|
|
const files = new Map<string, EditorFile>();
|
|
const add = (editor: EditorFile): void => {
|
|
const stem = fileStem(editor.file);
|
|
if (!stem) return;
|
|
files.set(`${editor.engineerId}:${stripGitPrefix(editor.file)}`, editor);
|
|
};
|
|
|
|
for (const [engineerId, git] of gitStates ?? []) {
|
|
for (const changed of git.changedFiles) {
|
|
add({ engineerId, file: changed });
|
|
}
|
|
}
|
|
|
|
for (const context of contexts) {
|
|
if (context.mode !== 'research' && context.currentFile) {
|
|
add({
|
|
engineerId: context.engineerId,
|
|
file: context.currentFile,
|
|
symbol: context.currentSymbol,
|
|
activity: context.activity,
|
|
});
|
|
}
|
|
}
|
|
|
|
return [...files.values()];
|
|
}
|
|
|
|
export async function detectResearchOverlaps(
|
|
contexts: EngineerContext[],
|
|
gitStates: Map<string, GitState> | undefined,
|
|
opts: ResearchOpts = {},
|
|
): Promise<Collision[]> {
|
|
const similarity = opts.similarity ?? semanticSimilarity;
|
|
const threshold = opts.threshold ?? env.RESEARCH_OVERLAP_THRESHOLD;
|
|
const researchers = contexts.filter((c) => c.mode === 'research' && c.researchTopic);
|
|
const editorFiles = collectEditorFiles(contexts, gitStates);
|
|
const bestByResearcher = new Map<string, Candidate>();
|
|
|
|
for (const researcher of researchers) {
|
|
const topic = researcher.researchTopic?.trim();
|
|
if (!topic) continue;
|
|
const source = researcher.researchSource?.trim();
|
|
const researchText = [topic, source].filter(Boolean).join(' ');
|
|
|
|
for (const editor of editorFiles) {
|
|
if (canonicalName(editor.engineerId) === canonicalName(researcher.engineerId)) continue;
|
|
|
|
const stem = fileStem(editor.file);
|
|
if (!stem) continue;
|
|
const fileText = [stem, editor.symbol, editor.activity].filter(Boolean).join(' ');
|
|
const score = await similarity(researchText, fileText);
|
|
const matched = score === null ? fallbackMatches(researchText, fileText) : score >= threshold;
|
|
if (!matched) continue;
|
|
|
|
const rank = score ?? 1;
|
|
const existing = bestByResearcher.get(researcher.engineerId);
|
|
if (existing && existing.score >= rank) continue;
|
|
|
|
bestByResearcher.set(researcher.engineerId, {
|
|
score: rank,
|
|
collision: {
|
|
id: `col_research_${stem}_${Date.now()}`,
|
|
podId: researcher.podId,
|
|
file: editor.file,
|
|
symbol: editor.symbol,
|
|
engineers: [editor.engineerId, researcher.engineerId],
|
|
severity: 'warn',
|
|
overlapKind: 'research',
|
|
researchTopic: topic,
|
|
...(source ? { researchSource: source } : {}),
|
|
researcher: researcher.engineerId,
|
|
editor: editor.engineerId,
|
|
detectedAt: new Date().toISOString(),
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
return [...bestByResearcher.values()].map((candidate) => candidate.collision);
|
|
}
|