Commit a3812f69 by PLN (Algolia)

Dunbar: NLP PAss

parent fbdcaf0f
...@@ -141,7 +141,6 @@ export default function DunbarApp() { ...@@ -141,7 +141,6 @@ export default function DunbarApp() {
router.push(`/dunbar/event/${eventSlug(e)}`, undefined, { shallow: true }); router.push(`/dunbar/event/${eventSlug(e)}`, undefined, { shallow: true });
}; };
if (!authed) {
// Deep-link handling: friend/event/search routes hydrate initial tab/selection // Deep-link handling: friend/event/search routes hydrate initial tab/selection
useEffect(() => { useEffect(() => {
if (!router || !router.asPath) return; if (!router || !router.asPath) return;
...@@ -180,6 +179,8 @@ export default function DunbarApp() { ...@@ -180,6 +179,8 @@ export default function DunbarApp() {
} }
}, [router?.asPath, friends, derived.eventIndex, actions]); }, [router?.asPath, friends, derived.eventIndex, actions]);
if (!authed) {
return ( return (
<div className={styles.lockWrap}> <div className={styles.lockWrap}>
<h2 className={styles.title}>Dunbar</h2> <h2 className={styles.title}>Dunbar</h2>
...@@ -283,7 +284,12 @@ export default function DunbarApp() { ...@@ -283,7 +284,12 @@ export default function DunbarApp() {
)} )}
{tab === 'stats' && ( {tab === 'stats' && (
<StatsTab stats={derived.stats} anniversaries={derived.anniversaries} openFriend={openFriendDetail} /> <StatsTab
stats={derived.stats}
anniversaries={derived.anniversaries}
eventIndex={derived.eventIndex}
openFriend={openFriendDetail}
/>
)} )}
</div> </div>
); );
......
...@@ -9,6 +9,7 @@ import { ...@@ -9,6 +9,7 @@ import {
isoDate, isoDate,
} from '@/lib/dunbar'; } from '@/lib/dunbar';
import { extractTags } from '@/lib/dunbar'; import { extractTags } from '@/lib/dunbar';
import { detectLang, topKeywordsForDocs, extractTopics } from '@/lib/dunbar-nlp';
export default function EventsTab({ friends, addEvent, updateEvent, selectedEventId, eventIndex, openEvent }) { export default function EventsTab({ friends, addEvent, updateEvent, selectedEventId, eventIndex, openEvent }) {
// Creation form state // Creation form state
...@@ -58,6 +59,36 @@ export default function EventsTab({ friends, addEvent, updateEvent, selectedEven ...@@ -58,6 +59,36 @@ export default function EventsTab({ friends, addEvent, updateEvent, selectedEven
// Timeline groups from merged eventIndex // Timeline groups from merged eventIndex
const groups = useMemo(() => groupEventsByDay(eventIndex), [eventIndex]); const groups = useMemo(() => groupEventsByDay(eventIndex), [eventIndex]);
// NLP: keywords per event (TF-IDF over corpus) and language guess
const keywordData = useMemo(() => {
const docs = (eventIndex || []).map((e) => ({
id: e.id,
text: `${e.title || ''} ${e.notes || ''}`,
}));
const corpusText = docs.map((d) => d.text).join(' ');
const lang = detectLang(corpusText) || null;
const top = topKeywordsForDocs(docs, { lang, topK: 6 });
const byId = new Map(top.map((d) => [d.id, d.keywords]));
return { byId, lang };
}, [eventIndex]);
// Topics (beta) — computed on demand
const [topics, setTopics] = useState([]);
const [topicsLoading, setTopicsLoading] = useState(false);
const runTopics = async () => {
try {
setTopicsLoading(true);
const docs = (eventIndex || []).map((e) => ({
id: e.id,
text: `${e.title || ''} ${e.notes || ''}`,
}));
const res = await extractTopics(docs, { topics: 5, termsPerTopic: 6, lang: keywordData.lang || null });
setTopics(res);
} finally {
setTopicsLoading(false);
}
};
// Selected event editor state // Selected event editor state
const selectedEvent = useMemo( const selectedEvent = useMemo(
() => (selectedEventId ? (eventIndex || []).find((e) => e.id === selectedEventId) : null), () => (selectedEventId ? (eventIndex || []).find((e) => e.id === selectedEventId) : null),
...@@ -219,7 +250,26 @@ export default function EventsTab({ friends, addEvent, updateEvent, selectedEven ...@@ -219,7 +250,26 @@ export default function EventsTab({ friends, addEvent, updateEvent, selectedEven
{/* Timeline + Editor */} {/* Timeline + Editor */}
<div className={styles.card}> <div className={styles.card}>
<div className={styles.cardHeader}>Timeline</div> <div className={styles.cardHeader}>
<span>Timeline</span>
<button
className={styles.btnSecondary}
onClick={runTopics}
disabled={topicsLoading || !(eventIndex || []).length}
title="Compute topics from titles+notes (local)"
>
{topicsLoading ? 'Topics…' : 'Topics (beta)'}
</button>
</div>
{topics && topics.length > 0 ? (
<div className={styles.tagRow} style={{ margin: '8px 0' }}>
{topics.map((t, i) => (
<span key={i} className={styles.tagChip} title={t.terms.map(([term]) => term).join(', ')}>
Topic {i + 1}: {t.terms.slice(0, 3).map(([term]) => term).join(' / ')}
</span>
))}
</div>
) : null}
{/* Event Editor */} {/* Event Editor */}
{selectedEvent ? ( {selectedEvent ? (
...@@ -329,6 +379,16 @@ export default function EventsTab({ friends, addEvent, updateEvent, selectedEven ...@@ -329,6 +379,16 @@ export default function EventsTab({ friends, addEvent, updateEvent, selectedEven
</div> </div>
) : null; ) : null;
})()} })()}
{(() => {
const kws = keywordData.byId.get(e.id) || [];
return kws.length ? (
<div className={styles.tagRow} style={{ marginTop: 4 }}>
{kws.slice(0, 6).map(([term]) => (
<span key={term} className={styles.tagChip}>{term}</span>
))}
</div>
) : null;
})()}
<div style={{ color: '#888', marginTop: 4, fontSize: 12 }}> <div style={{ color: '#888', marginTop: 4, fontSize: 12 }}>
{isoDate(e.date)} {isoDate(e.date)}
</div> </div>
......
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import styles from '@/styles/dunbar.module.css'; import styles from '@/styles/dunbar.module.css';
import { extractLocations } from '@/lib/dunbar-nlp';
export default function FriendsList({ export default function FriendsList({
friends, friends,
...@@ -35,6 +36,22 @@ export default function FriendsList({ ...@@ -35,6 +36,22 @@ export default function FriendsList({
return friends.filter((f) => f.name.toLowerCase().includes(q)); return friends.filter((f) => f.name.toLowerCase().includes(q));
}, [friends, filter]); }, [friends, filter]);
// Offline location mentions per friend (from notes + events)
const locByFriend = useMemo(() => {
const m = new Map();
for (const f of friends) {
let text = '';
text += ' ' + (f.notes || '');
for (const ev of f.events || []) {
text += ' ' + (ev.title || '') + ' ' + (ev.notes || '') + ' ' + (ev.location || '');
}
const locs = extractLocations(text).map((l) => l.name);
const uniq = Array.from(new Set(locs));
m.set(f.id, uniq);
}
return m;
}, [friends]);
const handleAdd = () => { const handleAdd = () => {
const n = name.trim(); const n = name.trim();
if (!n) return; if (!n) return;
...@@ -143,6 +160,10 @@ export default function FriendsList({ ...@@ -143,6 +160,10 @@ export default function FriendsList({
)} )}
<div className={styles.itemMeta}> <div className={styles.itemMeta}>
&nbsp;·&nbsp;{evCount} events · {connCount} connections &nbsp;·&nbsp;{evCount} events · {connCount} connections
{(() => {
const locs = locByFriend.get(f.id) || [];
return locs.length ? <> · 📍 {locs.slice(0, 2).join(', ')}</> : null;
})()}
</div> </div>
<div className={styles.itemRight} aria-hidden></div> <div className={styles.itemRight} aria-hidden></div>
<button <button
......
...@@ -7,6 +7,7 @@ import { ...@@ -7,6 +7,7 @@ import {
suggestTags, suggestTags,
suggestPersons, suggestPersons,
} from '@/lib/dunbar-search'; } from '@/lib/dunbar-search';
import { tokenize } from '@/lib/dunbar-nlp';
export default function SearchTab({ friends, openFriend, openEvent }) { export default function SearchTab({ friends, openFriend, openEvent }) {
const [q, setQ] = useState(''); const [q, setQ] = useState('');
...@@ -76,6 +77,35 @@ export default function SearchTab({ friends, openFriend, openEvent }) { ...@@ -76,6 +77,35 @@ export default function SearchTab({ friends, openFriend, openEvent }) {
return <>{parts}</>; return <>{parts}</>;
}; };
// Query tokens for highlight (non-hashtag, length>=3)
const queryTokens = useMemo(
() =>
tokenize(q || '', { keepHashtags: true, removeDiacritics: true }).filter(
(t) => !t.startsWith('#') && t.length >= 3
),
[q]
);
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const renderHighlight = (text = '', tokens = []) => {
if (!tokens || tokens.length === 0) return text;
const pattern = new RegExp(`(${tokens.map(escapeRegExp).join('|')})`, 'gi');
const parts = String(text).split(pattern);
const tokenSet = new Set(tokens.map((t) => t.toLowerCase()));
return (
<>
{parts.map((part, i) =>
tokenSet.has(String(part).toLowerCase()) ? (
<mark key={i} style={{ backgroundColor: '#fff2a8', padding: '0 2px' }}>{part}</mark>
) : (
<span key={i}>{part}</span>
)
)}
</>
);
};
const tagSuggestions = useMemo(() => (indexes ? suggestTags(indexes, q) : []), [indexes, q]); const tagSuggestions = useMemo(() => (indexes ? suggestTags(indexes, q) : []), [indexes, q]);
const personSuggestions = useMemo(() => (indexes ? suggestPersons(indexes, q) : []), [indexes, q]); const personSuggestions = useMemo(() => (indexes ? suggestPersons(indexes, q) : []), [indexes, q]);
...@@ -162,7 +192,7 @@ export default function SearchTab({ friends, openFriend, openEvent }) { ...@@ -162,7 +192,7 @@ export default function SearchTab({ friends, openFriend, openEvent }) {
<div className={styles.listScroll} style={{ maxHeight: '50vh' }}> <div className={styles.listScroll} style={{ maxHeight: '50vh' }}>
{(results.friends || []).map((f) => ( {(results.friends || []).map((f) => (
<div key={f.id} className={styles.listItem} onClick={() => openFriend?.(f.refId)}> <div key={f.id} className={styles.listItem} onClick={() => openFriend?.(f.refId)}>
<div className={styles.itemTitle}>{f.name}</div> <div className={styles.itemTitle}>{renderHighlight(f.name, queryTokens)}</div>
{(f.tags && f.tags.length) ? ( {(f.tags && f.tags.length) ? (
<div className={styles.tagRow}> <div className={styles.tagRow}>
{f.tags.slice(0, 8).map((t) => ( {f.tags.slice(0, 8).map((t) => (
...@@ -188,7 +218,7 @@ export default function SearchTab({ friends, openFriend, openEvent }) { ...@@ -188,7 +218,7 @@ export default function SearchTab({ friends, openFriend, openEvent }) {
onClick={() => openEvent?.(e)} onClick={() => openEvent?.(e)}
title="Ouvrir l’événement" title="Ouvrir l’événement"
> >
{e.title || '(untitled)'} {renderHighlight(e.title || '(untitled)', queryTokens)}
</div> </div>
<div style={{ whiteSpace: 'pre-wrap' }}> <div style={{ whiteSpace: 'pre-wrap' }}>
{renderNotesWithTags(e.notes || '')} {renderNotesWithTags(e.notes || '')}
......
import React from 'react'; import React from 'react';
import styles from '@/styles/dunbar.module.css'; import styles from '@/styles/dunbar.module.css';
import { isoDate } from '@/lib/dunbar'; import { isoDate } from '@/lib/dunbar';
import { detectLang, topKeywordsForDocs, extractLocations } from '@/lib/dunbar-nlp';
export default function StatsTab({ stats, anniversaries = [], openFriend }) { export default function StatsTab({ stats, anniversaries = [], eventIndex = [], openFriend }) {
if (!stats) return null; if (!stats) return null;
const items = [ const items = [
{ label: 'Connections', value: stats.connections }, { label: 'Connections', value: stats.connections },
...@@ -11,6 +12,46 @@ export default function StatsTab({ stats, anniversaries = [], openFriend }) { ...@@ -11,6 +12,46 @@ export default function StatsTab({ stats, anniversaries = [], openFriend }) {
{ label: 'Avg Events / Friend', value: stats.avgEventsPerFriend }, { label: 'Avg Events / Friend', value: stats.avgEventsPerFriend },
]; ];
// Aggregate text insights (local-only): top keywords and locations across all events
const textInsights = React.useMemo(() => {
const docs = (eventIndex || []).map((e) => ({
id: e.id,
text: `${e.title || ''} ${e.notes || ''} ${e.location || ''}`,
}));
if (!docs.length) return { topTerms: [], topLocations: [] };
const corpusText = docs.map((d) => d.text).join(' ');
const lang = detectLang(corpusText) || null;
// Aggregate keywords by summing TF-IDF heads across docs
const perDoc = topKeywordsForDocs(docs, { lang, topK: 8 });
const agg = new Map();
for (const d of perDoc) {
for (const [term, score] of d.keywords) {
agg.set(term, (agg.get(term) || 0) + score);
}
}
const topTerms = Array.from(agg.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 12)
.map(([term, score]) => ({ term, score }));
// Count location mentions (unique per event)
const locCounts = new Map();
for (const e of eventIndex || []) {
const locs = extractLocations(`${e.title || ''} ${e.notes || ''} ${e.location || ''}`).map((l) => l.name);
for (const name of new Set(locs)) {
locCounts.set(name, (locCounts.get(name) || 0) + 1);
}
}
const topLocations = Array.from(locCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 8)
.map(([name, count]) => ({ name, count }));
return { topTerms, topLocations };
}, [eventIndex]);
// Group upcoming anniversaries by date (YYYY-MM-DD) // Group upcoming anniversaries by date (YYYY-MM-DD)
const grouped = anniversaries.reduce((acc, it) => { const grouped = anniversaries.reduce((acc, it) => {
const k = isoDate(it.date); const k = isoDate(it.date);
...@@ -33,6 +74,32 @@ export default function StatsTab({ stats, anniversaries = [], openFriend }) { ...@@ -33,6 +74,32 @@ export default function StatsTab({ stats, anniversaries = [], openFriend }) {
))} ))}
</div> </div>
{(textInsights.topTerms.length > 0 || textInsights.topLocations.length > 0) && (
<div style={{ marginTop: 16 }}>
<div className={styles.cardHeader}><span>Text Insights</span></div>
{textInsights.topTerms.length > 0 ? (
<div style={{ marginBottom: 8 }}>
<div className={styles.itemMeta} style={{ marginBottom: 4 }}>Top keywords</div>
<div className={styles.tagRow}>
{textInsights.topTerms.map(({ term }) => (
<span key={term} className={styles.tagChip}>{term}</span>
))}
</div>
</div>
) : null}
{textInsights.topLocations.length > 0 ? (
<div>
<div className={styles.itemMeta} style={{ marginBottom: 4 }}>Top locations</div>
<div className={styles.tagRow}>
{textInsights.topLocations.map(({ name, count }) => (
<span key={name} className={styles.tagChip}>📍 {name} × {count}</span>
))}
</div>
</div>
) : null}
</div>
)}
{annivDays.length > 0 && ( {annivDays.length > 0 && (
<div style={{ marginTop: 16 }}> <div style={{ marginTop: 16 }}>
<div className={styles.cardHeader}> <div className={styles.cardHeader}>
......
...@@ -4,6 +4,7 @@ import styles from "./layout.module.css"; ...@@ -4,6 +4,7 @@ import styles from "./layout.module.css";
import utilStyles from "../styles/utils.module.css"; import utilStyles from "../styles/utils.module.css";
import Link from "next/link"; import Link from "next/link";
import Router from 'next/router' import Router from 'next/router'
import { useRouter } from 'next/router'
const name = "PLN"; const name = "PLN";
export const siteTitle = "PLN's Works"; export const siteTitle = "PLN's Works";
...@@ -12,6 +13,9 @@ export const twitterHandle = "@PaulLouisNech"; ...@@ -12,6 +13,9 @@ export const twitterHandle = "@PaulLouisNech";
export const description = "PLN's Selected Works"; export const description = "PLN's Selected Works";
export default function Layout({ children, home }) { export default function Layout({ children, home }) {
const router = useRouter();
const path = router?.asPath || router?.pathname || '';
const isDunbar = path.startsWith('/dunbar');
// Simple feedback launcher: prompts for text then opens default mail client // Simple feedback launcher: prompts for text then opens default mail client
const handleFeedbackMail = () => { const handleFeedbackMail = () => {
try { try {
...@@ -98,6 +102,8 @@ export default function Layout({ children, home }) { ...@@ -98,6 +102,8 @@ export default function Layout({ children, home }) {
> >
</a> </a>
{isDunbar && (
<>
{' '}|{' '} {' '}|{' '}
<button <button
type="button" type="button"
...@@ -108,6 +114,8 @@ export default function Layout({ children, home }) { ...@@ -108,6 +114,8 @@ export default function Layout({ children, home }) {
> >
Feedback (dunbar@nech.pl) Feedback (dunbar@nech.pl)
</button> </button>
</>
)}
</footer> </footer>
</div> </div>
); );
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment