8000 feat(notes): suggest links to the topics and named things a note mentions by adeelahmad · Pull Request #140 · adeelahmad/lense · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion DEVLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,9 @@ Todo:
- [x] Page history (note_version): a version before each change, one per sitting, restore that is itself undoable
- [x] Self-organising, first step: notes nobody filed are filed in PARA by a decision model (or the LLM), with a
suggestion when it isn't sure (ai.organise_notes)
- [ ] Self-organising, next: link notes to entities and topics, nest them under their project or area
- [x] Self-organising: suggested links to the topics and named things a note mentions (no model; one click; the
assistant sees them as could_link)
- [ ] Self-organising, next: nest notes under their project or area
- [ ] # topics move to the SKOS topics once the graph thread splits them from entities
- [ ] Attachments on a page, encrypted through the keyring
- [ ] Object storage (S3, or an rclone remote served as S3) in the setup wizard, no local storage; a downloadable
Expand Down
11 changes: 8 additions & 3 deletions docs/notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@ assistant write. The assistant is the main writer and organiser; people can do e
Status: **built**: pages (free notes in a tree, a page per recording, entity, topic, collection or speaker), @ and #
links with backlinks (# links topics), the tree in the left navigation, the page view with the BlockSuite editor, the
model keeping titles and summaries up to date, assistant tools to find, read, write and update notes, and MCP tools to
find, read and write them ([MCP](mcp.md)), and page history with restore.
**Planned**: linking notes to entities and topics and nesting them under their project or
area on their own, attachments on encrypted object storage, pages in the graph.
find, read and write them ([MCP](mcp.md)), page history with restore, and suggested links to what a note names.
**Planned**: nesting notes under their project or area on their own, attachments on encrypted object storage, pages in the graph.

## A page

Expand Down Expand Up @@ -43,6 +42,12 @@ found by any of their labels. Links written to terms (`#[Capsids](entity:9)`) be
Links are kept as rows (`note_link`), so every page lists its backlinks; links to things that are gone or that the
reader can't see show without a name.

A note also suggests links to what its text names but doesn't link yet: the namespace's topics (by any of their
labels) and its people, organisations, places and other named things (by name or alias; a single word only when it is
written with a capital). One click adds the link on a line of its own. The assistant sees the same list as
`could_link` when it reads a note. No model is asked: it is the namespace's own vocabulary matched against the words.
`GET /api/v1/notes/{id}/suggestions`.

## Titles and summaries

With **Settings → Assistant → Keep note titles and summaries up to date** (`ai.refine_notes`, on by default), a note
Expand Down
8 changes: 8 additions & 0 deletions fastapi_backend/app/api/v1/routes/notebook.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from app.schemas.common import Ok
from app.schemas.notebook import (
NoteHistory,
NoteLinkSuggestion,
NoteLinkTarget,
NotePage,
NotePageCreate,
Expand Down Expand Up @@ -154,6 +155,13 @@ def update_page(pid: int, body: NotePageUpdate, user: Writer, acl: Acl, db: Db)
return _out(acl, db, notebook.get(db, pid), user)


@router.get("/{pid}/suggestions")
def link_suggestions(pid: int, user: CurrentUser, acl: Acl, db: Db) -> list[NoteLinkSuggestion]:
"""What the page names but doesn't link yet: the namespace's topics and named things found in its text, to link
with one click. Matched against the namespace's own vocabulary; no model is asked."""
return [NoteLinkSuggestion(**x) for x in notebook.suggest_links(db, _page(acl, db, pid))]


def _version_out(db: DB, v: dict[str, Any], cls: type[NoteVersionItem] = NoteVersionItem) -> NoteVersionItem:
who = db.one("SELECT email FROM $r", r=store.R("account", v["by"])) if v.get("by") else None
return cls(**{**{k: x for k, x in v.items() if k not in ("by", "page", "doc")}, "by": (who or {}).get("email")})
Expand Down
1 change: 1 addition & 0 deletions fastapi_backend/app/domain/ai_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -847,6 +847,7 @@ def t_read_note(self, note_id=None, about=None):
"body": (p.get("body") or "")[:20_000],
"links": notebook.links(self.db, p["id"]),
"linked_from": [b for b in notebook.backlinks(self.db, p["space"], targets) if b["page"] != p["id"]],
"could_link": [f"{x['sign']}[{x['label']}]({x['target']})" for x in notebook.suggest_links(self.db, p)] or None,
}
)
return out, f"Read the note {p['title']}"
Expand Down
55 changes: 55 additions & 0 deletions fastapi_backend/app/domain/notebook.py
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,61 @@ def links(db, pid):
return [{"sign": s, "target": f"{k}:{i}", "label": label} for s, k, i, label in mentions(p.get("body"))]


SUGGEST_CHARS = 20_000 # of a page's text looked through for things to link
SUGGEST_MAX = 8
_TOKEN = re.compile(r"[\w][\w'’.-]*")
_NOT_LINKED = ("TERM", "DATE", "NUMBER", "TIME", "MONEY", "PERCENT", "QUANTITY", "ORDINAL", "CARDINAL")


def suggest_links(db, p, limit=SUGGEST_MAX):
"""What page p's text names but doesn't link yet: the namespace's topics (by any of their labels) and its people,
organisations, places and other named things (by name or alias), in the order the text names them. No model is
asked: it's the namespace's own vocabulary matched against the words. [{target, label, kind, sign}]"""
from . import analyze, topics

text = (p.get("text") or plain(p.get("body") or ""))[:SUGGEST_CHARS]
if not text.strip():
return []
sid = p["space"]
have = {t for _, k, i, _ in mentions(p.get("body")) for t in [f"{k}:{i}"]} | ({p["about"]} if p.get("about") else set())
by_key, by_term = topics._vocab(db, sid)
tlabel = {t["id"]: t["label"] for t in db.rows("SELECT record::id(id) AS id, label FROM topic WHERE space = $s", s=sid)}
ents = {}
for e in db.rows(
"SELECT record::id(id) AS id, name, type FROM entity WHERE space = $s AND type NOT IN $no AND hidden != true",
s=sid,
no=list(_NOT_LINKED),
):
ents.setdefault(analyze.ent_key(e["name"]), (e["id"], e["name"]))
names = {i: n for i, n in ents.values()}
for a in db.rows("SELECT entity, key FROM entity_alias WHERE space = $s", s=sid):
eid = int(str(a["entity"]).split(":")[-1]) if a.get("entity") is not None else None
if eid in names and a.get("key"):
ents.setdefault(a["key"], (eid, names[eid]))
words = _TOKEN.findall(text)
out, seen = [], set(have)
for i in range(len(words)):
for n in (4, 3, 2, 1):
gram = " ".join(words[i : i + n]).strip(".-'’")
if len(words[i : i + n]) < n or len(gram) < 3:
continue
hit = None
tid = topics._named(by_key, by_term, gram)
if tid and tid in tlabel:
hit = {"target": f"topic:{tid}", "label": tlabel[tid], "kind": "topic", &qu 9798 ot;sign": "#"}
elif n > 1 or gram[:1].isupper(): # a single word names a thing only when written with a capital
e = ents.get(analyze.ent_key(gram))
if e:
hit = {"target": f"entity:{e[0]}", "label": e[1], "kind": "entity", "sign": "@"}
if hit and hit["target"] not in seen:
seen.add(hit["target"])
out.append(hit)
break
if len(out) >= limit:
break
return out


def backlinks(db, sid, targets):
"""The pages of namespace `sid` that link to any of these targets ("page:3", "recording:12"): [{page, title}]."""
rows = db.rows(
Expand Down
4 changes: 4 additions & 0 deletions fastapi_backend/app/schemas/notebook.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,7 @@ class NoteLinkTarget(ResponseModel):
target: str
label: str
kind: Literal["page", "recording", "entity", "topic", "collection", "speaker"]


class NoteLinkSuggestion(NoteLinkTarget):
sign: Sign = Field(description="# for a topic, @ for anything else")
29 changes: 29 additions & 0 deletions fastapi_backend/tests/api/test_notebook.py
Original file line number Diff line number Diff line change
Expand Up @@ -319,3 +319,32 @@ def test_page_history(client, env, db, cfg, llm):
# deleting the page drops its history
client.delete(f"/api/v1/notes/{p['id']}", headers=he)
assert not db.rows("SELECT id FROM note_version WHERE page = $p", p=p["id"])


def test_link_suggestions(client, env, db, cfg):
from app.domain import ai_tools, topics

he, hv = env["he"], env["hv"]
pods = store.ns_id(db, "pods")
caps = topics.create(db, pods, "Capsid design", ["capsids"])
ada, acme = _entity(db, "Ada Lovelace", "PERSON"), _entity(db, "Acme", "ORG")
_entity(db, "Friday", "DATE")
body = (
f"Met Ada Lovelace about capsids on Friday; @[Acme](entity:{acme['id']}) ships them.\n\n"
"acme is lowercase here, and Ada Lovelace again."
)
p = _new(client, he, title="Meeting", body=body)
got = client.get(f"/api/v1/notes/{p['id']}/suggestions", headers=hv).json()
# in the order the text names them, each once; what's linked already, dates, and lowercase single words are left out
assert [(x["sign"], x["target"], x["label"]) for x in got] == [
("@", f"entity:{ada['id']}", "Ada Lovelace"),
("#", f"topic:{caps}", "Capsid design"),
]
# the assistant sees them when it reads the note
uid = db.one("SELECT record::id(id) AS id FROM account WHERE email = 'ed@x.io'")["id"]
box = ai_tools.Toolbox(db, cfg, {"id": uid, "email": "ed@x.io"}, {pods}, {pods}, {}, None)
import json

out = json.loads(box.call("read_note", {"note_id": p["id"]})[0])
assert out["could_link"] == [f"@[Ada Lovelace](entity:{ada['id']})", f"#[Capsid design](topic:{caps})"]
assert client.get(f"/api/v1/notes/{_new(client, he, title='Empty')['id']}/suggestions", headers=he).json() == []
6 changes: 6 additions & 0 deletions nextjs-frontend/app/openapi-client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1207,6 +1207,11 @@ export type {
LinkSpeakerErrors,
LinkSpeakerResponse,
LinkSpeakerResponses,
LinkSuggestionsData,
LinkSuggestionsError,
LinkSuggestionsErrors,
LinkSuggestionsResponse,
LinkSuggestionsResponses,
LinkTargetsData,
LinkTargetsError,
LinkTargetsErrors,
Expand Down Expand Up @@ -1626,6 +1631,7 @@ export type {
NoteCreate,
NoteHistory,
NoteLink,
NoteLinkSuggestion,
NoteLinkTarget,
NotePage,
NotePageCreate,
Expand Down
18 changes: 18 additions & 0 deletions nextjs-frontend/app/openapi-client/sdk.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,9 @@ import type {
LinkSpeakerData,
LinkSpeakerErrors,
LinkSpeakerResponses,
LinkSuggestionsData,
LinkSuggestionsErrors,
LinkSuggestionsResponses,
LinkTargetsData,
LinkTargetsErrors,
LinkTargetsResponses,
Expand Down Expand Up @@ -3835,6 +3838,21 @@ export class Notes {
});
}

/**
* Link Suggestions
*
* What the page names but doesn't link yet: the namespace's topics and named things found in its text, to link
* with one click. Matched against the namespace's own vocabulary; no model is asked.
*/
public static linkSuggestions<ThrowOnError extends boolean = false>(
options: Options<LinkSuggestionsData, ThrowOnError>,
): RequestResult<LinkSuggestionsResponses, LinkSuggestionsErrors, ThrowOnError> {
return (options.client ?? client).get<LinkSuggestionsResponses, LinkSuggestionsErrors, ThrowOnError>({
url: "/api/v1/notes/{pid}/suggestions",
...options,
});
}

/**
* Page History
*
Expand Down
57 changes: 57 additions & 0 deletions nextjs-frontend/app/openapi-client/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7233,6 +7233,31 @@ export type NoteLink = {
[key: string]: unknown;
};

/**
* NoteLinkSuggestion
*/
export type NoteLinkSuggestion = {
/**
* Target
*/
target: string;
/**
* Label
*/
label: string;
/**
* Kind
*/
kind: "page" | "recording" | "entity" | "topic" | "collection" | "speaker";
/**
* Sign
*
* # for a topic, @ for anything else
*/
sign: "@" | "#";
[key: string]: unknown;
};

/**
* NoteLinkTarget
*/
Expand Down Expand Up @@ -20122,6 +20147,38 @@ export type UpdatePageResponses = {

export type UpdatePageResponse = UpdatePageResponses[keyof UpdatePageResponses];

export type LinkSuggestionsData = {
body?: never;
path: {
/**
* Pid
*/
pid: number;
};
query?: never;
url: "/api/v1/notes/{pid}/suggestions";
};

export type LinkSuggestionsErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};

export type LinkSuggestionsError = LinkSuggestionsErrors[keyof LinkSuggestionsErrors];

export type LinkSuggestionsResponses = {
/**
* Response Notes-Link Suggestions
*
* Successful Response
*/
200: Array<NoteLinkSuggestion>;
};

export type LinkSuggestionsResponse = LinkSuggestionsResponses[keyof LinkSuggestionsResponses];

export type PageHistoryData = {
body?: never;
path: {
Expand Down
49 changes: 49 additions & 0 deletions nextjs-frontend/components/notes/link-suggestions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"use client";

import { useQuery } from "@tanstack/react-query";
import { Link2 } from "lucide-react";

import { Notes } from "@/app/openapi-client";
import type { NoteLinkSuggestion } from "@/app/openapi-client/types.gen";
import { data, useApiClient } from "@/lib/api/browser";

/** What the note names but doesn't link yet (the namespace's topics, people, organisations...): one click links it,
* so the note joins the graph. Found by matching the namespace's own vocabulary, without a model. */
export function LinkSuggestions({
pid,
version,
onLink,
busy,
}: {
pid: number;
version: string | null | undefined;
onLink: (s: NoteLinkSuggestion) => void;
busy: boolean;
}) {
const client = useApiClient();
const q = useQuery({
queryKey: ["note-suggestions", pid, version],
queryFn: () => data(Notes.linkSuggestions({ client, path: { pid } })),
staleTime: 30_000,
});
const items = q.data ?? [];
if (!items.length) return null;
return (
<div className="flex flex-wrap items-center gap-1.5 text-[12.5px] text-fg-muted" aria-label="Suggested links">
<Link2 className="size-3.5" aria-hidden />
<span>Link what it names:</span>
{items.map((s) => (
<button
key={s.target}
type="button"
disabled={busy}
=> onLink(s)}
className="rounded-full border border-border px-2 py-0.5 text-fg-strong hover:bg-surface-neutral disabled:opacity-50"
>
{s.sign}
{s.label}
</button>
))}
</div>
);
}
10BC8
31 changes: 30 additions & 1 deletion nextjs-frontend/components/notes/note-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Notes } from "@/app/openapi-client";
import type { NotePage as Page, NotePageDraft as PageDraft } from "@/app/openapi-client/types.gen";
import { ActivityPanel } from "@/components/costs/costs";
import type { EditorChange, LinkTarget } from "@/components/notes/block-editor";
import { LinkSuggestions } from "@/components/notes/link-suggestions";
import { NoteHistory } from "@/components/notes/note-history";
import { PLACES, hrefFor } from "@/components/notes/links";
import { Button } from "@/components/ui/button";
Expand Down Expand Up @@ -60,7 +61,8 @@ export function NotePage({ id, about }: { id?: number; about?: string }) {
const [date, setDate] = useState("");
const [place, setPlace] = useState("");
const [showHistory, setShowHistory] = useState(false);
const [restored, setRestored] = useState(0); // a restore brings the editor back with the old text
const [restored, setRestored] = useState(0); // a restore (or an added link) brings the editor back with the new text
const [linking, setLinking] = useState(false);
const [view, setView] = useState<View>("page");
const loadedFor = useRef<string | null>(null);
useEffect(() => {
Expand Down Expand Up @@ -335,6 +337,33 @@ export function NotePage({ id, about }: { id?: number; about?: string }) {
search={search}
>
/>
{canEdit && saved && (
<LinkSuggestions
pid={saved.id}
version={saved.updated_at}
busy={linking}
(s) => {
// whatever is being typed goes first; the link is added on a line of its own, and the editor reloads
setLinking(true);
try {
await flush();
const cur = qc.getQueryData<Page>(key) ?? saved;
const body = `${(cur.body ?? "").trimEnd()}\n\n${s.sign}[${s.label}](${s.target})`;
const out = await data(Notes.updatePage({ client, path: { pid: saved.id }, body: { body } }));
qc.setQueryData(key, out);
setRestored((n) => n + 1);
} catch (err) {
toast({
title: "Couldn’t link it",
body: err instanceof ApiError ? err.message : "Please try again.",
tone: "red",
});
} finally {
setLinking(false);
}
}}
/>
)}
<Backlinks items={page.backlinks ?? []} />
{showHistory && saved && (
<NoteHistory
Expand Down
Loading
Loading
0