feat(notes): liens internes, onglet Réseau, living blocks et consentement IA
Rend les liens entre notes visibles et persistants (sync NoteLink au save, auto-save, graphe réseau rafraîchi), ajoute living blocks, Memory Echo, recherche globale, consentement IA explicite et consolide les prototypes design en architectural-grid. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
242
architectural-grid/src/services/clusteringService.ts
Normal file
242
architectural-grid/src/services/clusteringService.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
|
||||
import { Note, NoteCluster, BridgeNote } from '../types';
|
||||
import { cosineSimilarity } from './geminiService';
|
||||
|
||||
export function dbscan(notes: Note[], eps: number, minPts: number): number[] {
|
||||
const n = notes.length;
|
||||
const labels = new Array(n).fill(-1); // -1 = noise, 0+ = cluster id
|
||||
let clusterId = 0;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (labels[i] !== -1) continue;
|
||||
|
||||
const neighbors = getNeighbors(i, notes, eps);
|
||||
|
||||
if (neighbors.length < minPts) {
|
||||
labels[i] = -1; // remains noise for now
|
||||
continue;
|
||||
}
|
||||
|
||||
labels[i] = clusterId;
|
||||
const queue = neighbors.filter(idx => idx !== i);
|
||||
|
||||
for (let j = 0; j < queue.length; j++) {
|
||||
const pIdx = queue[j];
|
||||
|
||||
if (labels[pIdx] === -1) {
|
||||
labels[pIdx] = clusterId; // noisy point becomes border point
|
||||
}
|
||||
|
||||
if (labels[pIdx] !== -1 && labels[pIdx] < clusterId) {
|
||||
// This should not happen in standard DBSCAN unless we re-visit
|
||||
}
|
||||
|
||||
if (labels[pIdx] === clusterId && labels[pIdx] !== -1) {
|
||||
// Skip if already processed in this cluster
|
||||
}
|
||||
|
||||
// If it was already labeled, skip re-neighboring
|
||||
const pWasNoise = labels[pIdx] === -1;
|
||||
if (labels[pIdx] === -1) labels[pIdx] = clusterId;
|
||||
|
||||
// If point was not processed
|
||||
if (pWasNoise || labels[pIdx] === clusterId ) {
|
||||
// This is a simplified queue processing
|
||||
}
|
||||
}
|
||||
|
||||
// Standard DBSCAN expansion
|
||||
expandCluster(i, neighbors, labels, clusterId, notes, eps, minPts);
|
||||
clusterId++;
|
||||
}
|
||||
|
||||
return labels;
|
||||
}
|
||||
|
||||
function expandCluster(pIdx: number, neighbors: number[], labels: number[], clusterId: number, notes: Note[], eps: number, minPts: number) {
|
||||
let i = 0;
|
||||
while (i < neighbors.length) {
|
||||
const qIdx = neighbors[i];
|
||||
if (labels[qIdx] === -1) {
|
||||
labels[qIdx] = clusterId;
|
||||
} else if (labels[qIdx] === undefined || labels[qIdx] === -1) {
|
||||
// unreachable
|
||||
}
|
||||
|
||||
if (labels[qIdx] === clusterId || labels[qIdx] === -1) {
|
||||
const qNeighbors = getNeighbors(qIdx, notes, eps);
|
||||
if (qNeighbors.length >= minPts) {
|
||||
for(const qn of qNeighbors) {
|
||||
if (labels[qn] === -1) {
|
||||
labels[qn] = clusterId;
|
||||
neighbors.push(qn);
|
||||
} else if (!labels.hasOwnProperty(qn)) {
|
||||
// logic error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean DBSCAN implementation
|
||||
export function runClustering(notes: Note[], eps: number = 0.15, minPts: number = 2): { labels: number[], clusters: NoteCluster[] } {
|
||||
const validNotes = notes.filter(n => n.embedding && n.embedding.length > 0);
|
||||
if (validNotes.length === 0) return { labels: [], clusters: [] };
|
||||
|
||||
const n = validNotes.length;
|
||||
const labels = new Array(n).fill(-1);
|
||||
let cId = 0;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (labels[i] !== -1) continue;
|
||||
|
||||
const neighbors = findNeighbors(i, validNotes, eps);
|
||||
if (neighbors.length < minPts) {
|
||||
labels[i] = -1;
|
||||
} else {
|
||||
labels[i] = cId;
|
||||
expand(i, neighbors, labels, cId, validNotes, eps, minPts);
|
||||
cId++;
|
||||
}
|
||||
}
|
||||
|
||||
const clusters: NoteCluster[] = [];
|
||||
const colorPalette = ['#F87171', '#60A5FA', '#34D399', '#FBBF24', '#A78BFA', '#F472B6', '#2DD4BF'];
|
||||
|
||||
for (let i = 0; i < cId; i++) {
|
||||
const noteIds = validNotes.filter((_, idx) => labels[idx] === i).map(n => n.id);
|
||||
clusters.push({
|
||||
id: `cluster-${i}`,
|
||||
name: `Cluster ${i + 1}`,
|
||||
noteIds,
|
||||
color: colorPalette[i % colorPalette.length]
|
||||
});
|
||||
}
|
||||
|
||||
return { labels, clusters };
|
||||
}
|
||||
|
||||
function findNeighbors(idx: number, notes: Note[], eps: number): number[] {
|
||||
const neighbors: number[] = [];
|
||||
const targetEmbedding = notes[idx].embedding!;
|
||||
for (let i = 0; i < notes.length; i++) {
|
||||
const sim = cosineSimilarity(targetEmbedding, notes[i].embedding!);
|
||||
const dist = 1 - sim;
|
||||
if (dist <= eps) {
|
||||
neighbors.push(i);
|
||||
}
|
||||
}
|
||||
return neighbors;
|
||||
}
|
||||
|
||||
function expand(rootIdx: number, neighbors: number[], labels: number[], cId: number, notes: Note[], eps: number, minPts: number) {
|
||||
const queue = [...neighbors];
|
||||
for (let i = 0; i < queue.length; i++) {
|
||||
const qIdx = queue[i];
|
||||
if (labels[qIdx] === -1) {
|
||||
labels[qIdx] = cId;
|
||||
}
|
||||
if (labels[qIdx] !== -1 && labels[qIdx] !== cId) continue;
|
||||
if (labels[qIdx] === cId) {
|
||||
// already visited but let's check neighbors if we just added it
|
||||
}
|
||||
|
||||
// If point was noise, it now belongs to cluster, but we don't necessarily expand from it unless it's a core point
|
||||
// This is the standard DBSCAN: noise points can become border points
|
||||
}
|
||||
|
||||
// Re-implementing correctly
|
||||
let head = 0;
|
||||
while(head < queue.length) {
|
||||
const qIdx = queue[head];
|
||||
if (labels[qIdx] === -1) labels[qIdx] = cId;
|
||||
if (labels[qIdx] === cId) {
|
||||
const qNeighbors = findNeighbors(qIdx, notes, eps);
|
||||
if (qNeighbors.length >= minPts) {
|
||||
for(const qn of qNeighbors) {
|
||||
if (labels[qn] === -1) {
|
||||
labels[qn] = cId;
|
||||
queue.push(qn);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
head++;
|
||||
}
|
||||
}
|
||||
|
||||
function getNeighbors(idx: number, notes: Note[], eps: number): number[] {
|
||||
const neighbors: number[] = [];
|
||||
const target = notes[idx].embedding!;
|
||||
for (let i = 0; i < notes.length; i++) {
|
||||
if (!notes[i].embedding) continue;
|
||||
const dist = 1 - cosineSimilarity(target, notes[i].embedding!);
|
||||
if (dist <= eps) neighbors.push(i);
|
||||
}
|
||||
return neighbors;
|
||||
}
|
||||
|
||||
export function detectBridges(notes: Note[], clusters: NoteCluster[], threshold: number = 0.5): BridgeNote[] {
|
||||
const bridges: BridgeNote[] = [];
|
||||
const validNotes = notes.filter(n => n.embedding);
|
||||
|
||||
for (const note of validNotes) {
|
||||
const connectedClusters = new Set<string>();
|
||||
|
||||
for (const cluster of clusters) {
|
||||
// Check if note has strong links to ANY note in this cluster
|
||||
const clusterNotes = notes.filter(n => cluster.noteIds.includes(n.id) && n.embedding);
|
||||
const hasStrongLink = clusterNotes.some(cn => cosineSimilarity(note.embedding!, cn.embedding!) > threshold);
|
||||
|
||||
if (hasStrongLink) {
|
||||
connectedClusters.add(cluster.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (connectedClusters.size >= 2) {
|
||||
bridges.push({
|
||||
noteId: note.id,
|
||||
connectedClusterIds: Array.from(connectedClusters),
|
||||
bridgeScore: connectedClusters.size / Math.max(clusters.length, 1)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return bridges.sort((a, b) => b.bridgeScore - a.bridgeScore);
|
||||
}
|
||||
|
||||
export function calculateCentroid(noteIds: string[], allNotes: Note[]): number[] | undefined {
|
||||
const clusterNotes = allNotes.filter(n => noteIds.includes(n.id) && n.embedding);
|
||||
if (clusterNotes.length === 0) return undefined;
|
||||
|
||||
const embeddingDim = clusterNotes[0].embedding!.length;
|
||||
const centroid = new Array(embeddingDim).fill(0);
|
||||
|
||||
for (const note of clusterNotes) {
|
||||
for (let i = 0; i < embeddingDim; i++) {
|
||||
centroid[i] += note.embedding![i];
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < embeddingDim; i++) {
|
||||
centroid[i] /= clusterNotes.length;
|
||||
}
|
||||
|
||||
return centroid;
|
||||
}
|
||||
|
||||
export function getMostCentralNoteTitles(noteIds: string[], centroid: number[] | undefined, allNotes: Note[], count: number = 5): string[] {
|
||||
const clusterNotes = allNotes.filter(n => noteIds.includes(n.id) && n.embedding);
|
||||
if (clusterNotes.length === 0) return [];
|
||||
if (!centroid) return clusterNotes.slice(0, count).map(n => n.title);
|
||||
|
||||
const scored = clusterNotes.map(n => ({
|
||||
title: n.title,
|
||||
similarity: cosineSimilarity(n.embedding!, centroid)
|
||||
}));
|
||||
|
||||
scored.sort((a, b) => b.similarity - a.similarity);
|
||||
return scored.slice(0, count).map(item => item.title);
|
||||
}
|
||||
313
architectural-grid/src/services/geminiService.ts
Normal file
313
architectural-grid/src/services/geminiService.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
|
||||
import { GoogleGenAI, Type } from "@google/genai";
|
||||
import { BrainstormIdea } from "../types";
|
||||
|
||||
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
|
||||
|
||||
const BRAINSTORM_SCHEMA = {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
ideas: {
|
||||
type: Type.ARRAY,
|
||||
items: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
title: { type: Type.STRING },
|
||||
description: { type: Type.STRING },
|
||||
connection_to_seed: { type: Type.STRING },
|
||||
novelty_score: { type: Type.NUMBER }
|
||||
},
|
||||
required: ["title", "description", "connection_to_seed", "novelty_score"]
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ["ideas"]
|
||||
};
|
||||
|
||||
const SUGGESTIONS_SCHEMA = {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
suggestions: {
|
||||
type: Type.ARRAY,
|
||||
items: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
title: { type: Type.STRING },
|
||||
description: { type: Type.STRING },
|
||||
reasoning: { type: Type.STRING }
|
||||
},
|
||||
required: ["title", "description", "reasoning"]
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ["suggestions"]
|
||||
};
|
||||
|
||||
export async function generateBrainstormWave(
|
||||
seedIdea: string,
|
||||
waveNumber: number,
|
||||
contextSummaries: string = ""
|
||||
): Promise<Partial<BrainstormIdea>[]> {
|
||||
const waveDescriptions = [
|
||||
"", // index 0 unused
|
||||
"VAGUE 1 (proximité directe) : Sous-aspects, reformulations, variations de l'idée. Reste dans le même domaine.",
|
||||
"VAGUE 2 (analogies) : Trouve des parallèles dans d'autres domaines. Comment cette idée se manifeste-t-elle ailleurs ? Quelles techniques d'autres industries pourraient s'appliquer ?",
|
||||
"VAGUE 3 (disruption) : Inverse l'idée. Pousse-la à l'extrême. Combine-la avec un domaine totalement non lié. Que se passe-t-il si l'opposé est vrai ?"
|
||||
];
|
||||
|
||||
const prompt = `
|
||||
Idée seed : "${seedIdea}"
|
||||
Contexte : ${contextSummaries}
|
||||
Génère 5 idées pour la VAGUE ${waveNumber} : ${waveDescriptions[waveNumber]}
|
||||
Format JSON selon le schéma.
|
||||
`;
|
||||
|
||||
try {
|
||||
const response = await ai.models.generateContent({
|
||||
model: "gemini-3-flash-preview",
|
||||
contents: [{ role: "user", parts: [{ text: prompt }] }],
|
||||
config: {
|
||||
systemInstruction: "Tu es un expert en brainstorming. Réponds uniquement en JSON valide.",
|
||||
responseMimeType: "application/json",
|
||||
responseSchema: BRAINSTORM_SCHEMA,
|
||||
temperature: 1.0
|
||||
}
|
||||
});
|
||||
|
||||
const resText = response.text;
|
||||
if (!resText) return [];
|
||||
|
||||
const parsed = JSON.parse(resText.replace(/^```json\n?/, '').replace(/\n?```$/, '').trim());
|
||||
const ideas = Array.isArray(parsed.ideas) ? parsed.ideas : (Array.isArray(parsed) ? parsed : []);
|
||||
|
||||
return ideas.map((item: any) => ({
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
connectionToSeed: item.connection_to_seed,
|
||||
noveltyScore: item.novelty_score,
|
||||
waveNumber
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(`Error generating brainstorm wave ${waveNumber}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateExpansion(parentIdeaTitle: string, parentIdeaDescription: string): Promise<Partial<BrainstormIdea>[]> {
|
||||
const prompt = `
|
||||
Idée source : "${parentIdeaTitle} - ${parentIdeaDescription}"
|
||||
Génère 3 idées d'extension ou de sous-aspects.
|
||||
Format JSON.
|
||||
`;
|
||||
|
||||
try {
|
||||
const response = await ai.models.generateContent({
|
||||
model: "gemini-3-flash-preview",
|
||||
contents: [{ role: "user", parts: [{ text: prompt }] }],
|
||||
config: {
|
||||
systemInstruction: "Tu es un expert en brainstorming. Réponds uniquement en JSON valide.",
|
||||
responseMimeType: "application/json",
|
||||
responseSchema: BRAINSTORM_SCHEMA,
|
||||
temperature: 1.0
|
||||
}
|
||||
});
|
||||
|
||||
const resText = response.text;
|
||||
if (!resText) return [];
|
||||
|
||||
const parsed = JSON.parse(resText.replace(/^```json\n?/, '').replace(/\n?```$/, '').trim());
|
||||
const ideas = Array.isArray(parsed.ideas) ? parsed.ideas : (Array.isArray(parsed) ? parsed : []);
|
||||
|
||||
return ideas.map((item: any) => ({
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
connectionToSeed: item.connection_to_seed,
|
||||
noveltyScore: item.novelty_score
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Error generating expansion:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getEmbedding(text: string): Promise<number[]> {
|
||||
try {
|
||||
const result = await ai.models.embedContent({
|
||||
model: 'gemini-embedding-2-preview',
|
||||
contents: [text],
|
||||
});
|
||||
return result.embeddings[0].values;
|
||||
} catch (error) {
|
||||
console.error("Error generating embedding:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function cosineSimilarity(a: number[], b: number[]): number {
|
||||
if (!a || !b || a.length !== b.length) return 0;
|
||||
const dotProduct = a.reduce((sum, val, i) => sum + val * b[i], 0);
|
||||
const magnitudeA = Math.sqrt(a.reduce((sum, val) => sum + val * val, 0));
|
||||
const magnitudeB = Math.sqrt(b.reduce((sum, val) => sum + val * val, 0));
|
||||
if (magnitudeA === 0 || magnitudeB === 0) return 0;
|
||||
return dotProduct / (magnitudeA * magnitudeB);
|
||||
}
|
||||
|
||||
export async function nameCluster(noteSummaries: string[]): Promise<string> {
|
||||
const prompt = `Quel thème commun relie ces notes ? Donne un nom court (2-4 mots).\nNotes :\n${noteSummaries.join('\n- ')}`;
|
||||
try {
|
||||
const result = await ai.models.generateContent({
|
||||
model: "gemini-3-flash-preview",
|
||||
contents: prompt
|
||||
});
|
||||
return result.text.trim();
|
||||
} catch (error) {
|
||||
console.error("Error naming cluster:", error);
|
||||
return "Thematic Cluster";
|
||||
}
|
||||
}
|
||||
|
||||
export async function suggestBridgeIdeas(
|
||||
clusterAName: string,
|
||||
clusterBName: string,
|
||||
clusterASummaries: string,
|
||||
clusterBSummaries: string
|
||||
): Promise<any[]> {
|
||||
const prompt = `
|
||||
Cluster A (${clusterAName}) contient des notes sur : ${clusterASummaries}
|
||||
Cluster B (${clusterBName}) contient des notes sur : ${clusterBSummaries}
|
||||
|
||||
Ces deux clusters ne sont pas connectés. Propose 3 idées
|
||||
de "notes pont" qui pourraient créer un lien créatif entre eux.
|
||||
Pour chaque idée : titre, description, pourquoi ça connecte les deux.
|
||||
|
||||
Format JSON.
|
||||
`;
|
||||
|
||||
try {
|
||||
const response = await ai.models.generateContent({
|
||||
model: "gemini-3-flash-preview",
|
||||
contents: prompt,
|
||||
config: {
|
||||
responseMimeType: "application/json",
|
||||
responseSchema: SUGGESTIONS_SCHEMA
|
||||
}
|
||||
});
|
||||
const parsed = JSON.parse(response.text);
|
||||
return Array.isArray(parsed.suggestions) ? parsed.suggestions : [];
|
||||
} catch (error) {
|
||||
console.error("Error suggesting bridge ideas:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
export async function parseDocument(fileUrl: string, fileName: string): Promise<string> {
|
||||
const prompt = `Extraits et résume le texte de ce document nommé "${fileName}".
|
||||
Si c'est un PDF, ignore les éléments purement graphiques et concentre-toi sur le contenu sémantique.
|
||||
Fais une extraction structurée.`;
|
||||
|
||||
try {
|
||||
// In a real scenario, we would use media upload.
|
||||
// Here we simulate the extraction.
|
||||
const response = await ai.models.generateContent({
|
||||
model: "gemini-3-flash-preview",
|
||||
contents: [{ role: "user", parts: [{ text: prompt }] }],
|
||||
config: {
|
||||
systemInstruction: "Tu es un expert en extraction de texte et analyse de documents.",
|
||||
temperature: 0.2
|
||||
}
|
||||
});
|
||||
|
||||
return response.text || "Échec de l'extraction du texte.";
|
||||
} catch (error) {
|
||||
console.error("Error parsing document:", error);
|
||||
return "Erreur lors de l'analyse du document.";
|
||||
}
|
||||
}
|
||||
|
||||
export async function extractActionItems(notes: { title: string; content: string }[]): Promise<string> {
|
||||
const notesContext = notes.map(n => `TITLE: ${n.title}\nCONTENT: ${n.content}`).join('\n\n---\n\n');
|
||||
const prompt = `
|
||||
Analyse les notes suivantes et extrais la liste des actions à accomplir (TODOs).
|
||||
Pour chaque tâche, identifie si possible l'assigné et la date limite.
|
||||
Présente le résultat sous forme d'un tableau Markdown structuré ou d'une liste claire.
|
||||
Si aucune tâche n'est trouvée, indique-le.
|
||||
|
||||
Notes:
|
||||
${notesContext}
|
||||
`;
|
||||
|
||||
try {
|
||||
const response = await ai.models.generateContent({
|
||||
model: "gemini-3-flash-preview",
|
||||
contents: prompt,
|
||||
config: {
|
||||
systemInstruction: "Tu es un agent spécialisé dans l'organisation et la gestion de tâches. Ton but est d'être précis et exhaustif.",
|
||||
temperature: 0.1
|
||||
}
|
||||
});
|
||||
|
||||
return response.text;
|
||||
} catch (error) {
|
||||
console.error("Error extracting action items:", error);
|
||||
return "Erreur lors de l'extraction des tâches.";
|
||||
}
|
||||
}
|
||||
|
||||
const FLASHCARDS_SCHEMA = {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
flashcards: {
|
||||
type: Type.ARRAY,
|
||||
items: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
question: { type: Type.STRING },
|
||||
answer: { type: Type.STRING }
|
||||
},
|
||||
required: ["question", "answer"]
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ["flashcards"]
|
||||
};
|
||||
|
||||
export async function generateFlashcardsForNote(
|
||||
noteTitle: string,
|
||||
noteContent: string
|
||||
): Promise<{ question: string; answer: string }[]> {
|
||||
const prompt = `
|
||||
Titre de la note : "${noteTitle}"
|
||||
Contenu de la note :
|
||||
${noteContent}
|
||||
|
||||
Génère entre 4 et 8 flashcards (paires question/réponse) d'apprentissage basées sur le contenu ci-dessus.
|
||||
|
||||
Règles de style :
|
||||
- Les questions doivent être claires et guider vers une révision active (ex: "Quelle est la particularité de... ?", "Pourquoi utilise-t-on... ?").
|
||||
- Les réponses doivent être courtes et percutantes.
|
||||
- Langue : Français.
|
||||
- Format de retour : JSON correspondant au schéma.
|
||||
`;
|
||||
|
||||
try {
|
||||
const response = await ai.models.generateContent({
|
||||
model: "gemini-3.5-flash",
|
||||
contents: [{ role: "user", parts: [{ text: prompt }] }],
|
||||
config: {
|
||||
systemInstruction: "Tu es un assistant de révision agile. Tu convertis le contenu d'un cours ou d'une note en de superbes flashcards mémo-techniques.",
|
||||
responseMimeType: "application/json",
|
||||
responseSchema: FLASHCARDS_SCHEMA,
|
||||
temperature: 0.7
|
||||
}
|
||||
});
|
||||
|
||||
const resText = response.text;
|
||||
if (!resText) return [];
|
||||
|
||||
const parsed = JSON.parse(resText.replace(/^```json\n?/, '').replace(/\n?```$/, '').trim());
|
||||
return Array.isArray(parsed.flashcards) ? parsed.flashcards : (Array.isArray(parsed) ? parsed : []);
|
||||
} catch (error) {
|
||||
console.error("Error generating flashcards with Gemini:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
76
architectural-grid/src/services/temporalService.ts
Normal file
76
architectural-grid/src/services/temporalService.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Note, NoteAccessLog, NotePrediction } from '../types';
|
||||
|
||||
/**
|
||||
* Simulates finding the dominant frequency in access logs for a specific note
|
||||
* returning the period in days.
|
||||
*/
|
||||
export function detectAccessCycle(logs: NoteAccessLog[]): number | null {
|
||||
if (logs.length < 5) return null;
|
||||
|
||||
const accessDays = logs
|
||||
.map(log => new Date(log.accessedAt).getTime())
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
const intervals: number[] = [];
|
||||
for (let i = 1; i < accessDays.length; i++) {
|
||||
intervals.push((accessDays[i] - accessDays[i - 1]) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
// Simple heuristic: if intervals are consistently around a value, that's our cycle
|
||||
// We'll calculate the median interval
|
||||
const sortedIntervals = [...intervals].sort((a, b) => a - b);
|
||||
const median = sortedIntervals[Math.floor(sortedIntervals.length / 2)];
|
||||
|
||||
// Check if enough intervals are close to median
|
||||
const withinThreshold = intervals.filter(v => Math.abs(v - median) < Math.max(2, median * 0.2));
|
||||
|
||||
if (withinThreshold.length >= intervals.length * 0.6) {
|
||||
return median;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function predictNextAccess(note: Note, logs: NoteAccessLog[]): NotePrediction | null {
|
||||
const cycleDays = detectAccessCycle(logs);
|
||||
if (!cycleDays) return null;
|
||||
|
||||
const lastAccess = new Date(logs[logs.length - 1].accessedAt);
|
||||
const nextAccessDate = new Date(lastAccess.getTime() + cycleDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
const now = new Date();
|
||||
const daysUntilNext = (nextAccessDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24);
|
||||
|
||||
// Only predict if it's coming up in the next 2 weeks
|
||||
if (daysUntilNext > 0 && daysUntilNext < 14) {
|
||||
return {
|
||||
noteId: note.id,
|
||||
predictedRelevanceDate: nextAccessDate.toISOString(),
|
||||
confidence: 0.7,
|
||||
reason: `Historical access pattern suggests a ${Math.round(cycleDays)}-day cycle.`,
|
||||
generatedAt: now.toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getCoaccessedNotes(baseNoteId: string, logs: NoteAccessLog[], allNotes: Note[]): Note[] {
|
||||
const WINDOW_MS = 30 * 60 * 1000; // 30 minutes
|
||||
|
||||
const baseNoteLogs = logs.filter(l => l.noteId === baseNoteId);
|
||||
const coaccessedIds = new Set<string>();
|
||||
|
||||
baseNoteLogs.forEach(baseLog => {
|
||||
const baseTime = new Date(baseLog.accessedAt).getTime();
|
||||
logs.forEach(otherLog => {
|
||||
if (otherLog.noteId === baseNoteId) return;
|
||||
const otherTime = new Date(otherLog.accessedAt).getTime();
|
||||
if (Math.abs(baseTime - otherTime) < WINDOW_MS) {
|
||||
coaccessedIds.add(otherLog.noteId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return allNotes.filter(n => coaccessedIds.has(n.id));
|
||||
}
|
||||
Reference in New Issue
Block a user