8000 feat(notes): suggest the project or area a top-level note fits under by adeelahmad · Pull Request #142 · 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
8000
Diff view
6 changes: 4 additions & 2 deletions DEVLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,8 +207,10 @@ Todo:
suggestion when it isn't sure (ai.organise_notes)
- [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
- [x] Self-organising: the project or area a top-level note fits under, from shared links and title words (no model;
one click; the assistant sees them as could_go_in)
- [ ] Self-organising, next: nest notes without a click when the fit is clear (decision model, like PARA filing)
- [x] # topics move to the SKOS topics once the graph thread splits them from entities (#135)
- [ ] 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
256-bit storage key. Design proposal waiting on Adeel (changes existing installs)
Expand Down
9 changes: 7 additions & 2 deletions docs/notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +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)), 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.
find, read and write them ([MCP](mcp.md)), page history with restore, suggested links to what a note names, and the project or area a note fits under.
**Planned**: nesting notes under their project or area without a click, attachments on encrypted object storage, pages in the graph.

## A page

Expand Down Expand Up @@ -66,6 +66,11 @@ takes it when one is set up (Settings → Assistant), else the language model. W
note is filed (`place_by: assistant`); otherwise its best guess waits on the note as a suggestion to accept with one
click. What a person files is never refiled.

A note at the top of the tree that isn't a project, an area or archived also shows the project or area page it fits
under, with one click to put it inside: the one it links to, that links to it, that links the same topics and things,
or whose title words it uses (`GET /api/v1/notes/{id}/homes`, best first, with why). No model is asked and nothing
moves on its own; the assistant sees the same as `could_go_in` when it reads the note, and moves it with `update_note`.

## The assistant

The chat assistant keeps notes as its notebook: `find_notes`, `read_note`, `write_note` and `update_note` (Settings →
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,
NoteHomeSuggestion,
NoteLinkSuggestion,
NoteLinkTarget,
NotePage,
Expand Down Expand Up @@ -162,6 +163,13 @@ def link_suggestions(pid: int, user: CurrentUser, acl: Acl, db: Db) -> list[Note
return [NoteLinkSuggestion(**x) for x in notebook.suggest_links(db, _page(acl, db, pid))]


@router.get("/{pid}/homes")
def home_suggestions(pid: int, user: CurrentUser, acl: Acl, db: Db) -> list[NoteHomeSuggestion]:
"""The project or area pages a free note at the top of the tree could go inside, best first, from the links and
words they share; moving it there is one click (POST /{pid}/move). No model is asked."""
return [NoteHomeSuggestion(**x) for x in notebook.suggest_homes(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
7 changes: 6 additions & 1 deletion fastapi_backend/app/domain/ai_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@
(
"update_note",
"Change a note: its title, summary, place, or its text (body replaces it; append adds to the end). Move a free "
"note in the tree with parent_id (0: the top).",
"note in the tree with parent_id (0: the top); read_note lists could_go_in, the projects and areas it fits under.",
{
"note_id": _I,
"title": _S,
Expand Down Expand Up @@ -848,6 +848,11 @@ def t_read_note(self, note_id=None, about=None):
"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,
"could_go_in": [
{"parent_id": x["page"], "title": x["title"], "place": x["place"], "why": x["why"]}
for x in notebook.suggest_homes(self.db, p)
]
or None,
}
)
return out, f"Read the note {p['title']}"
Expand Down
61 changes: 61 additions & 0 deletions fastapi_backend/app/domain/notebook.py
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,67 @@ def suggest_links(db, p, limit=SUGGEST_MAX):
return out


HOMES = ("project", "area") # where a note can be nested: under a page filed as a project or an area
HOMES_MAX = 3
_WORD = re.compile(r"[^\W\d_]{4,}")
_GENERIC = {"note", "notes", "page", "pages", "project", "projects", "area", "areas", "plan", "plans", "idea", "ideas"}


def _words(text):
from .analyze import STOP

return {w for w in (x.lower() for x in _WORD.findall(text or "")) if w not in STOP and w not in _GENERIC}


def suggest_homes(db, p, limit=HOMES_MAX):
"""The project or area pages a free note at the top of the tree could go inside, best first: the ones it links to,
that link to it, that link the same topics and things, or whose title it names. No model is asked, and nothing
moves until someone (or the assistant) moves it. Only unnested notes that aren't projects, areas or archived.
[{page, title, place, score, why}]"""
if p.get("about") or p.get("parent") is not None or p.get("place") in (*HOMES, "archive"):
return []
sid, pid = p["space"], int(p["id"])
pages = db.rows("SELECT record::id(id) AS id, title, place, parent FROM note_page WHERE space = $s AND about = NONE", s=sid)
up = {x["id"]: x.get("parent") for x in pages}

def inside(x): # x is p or somewhere under it
seen = set()
while x is not None and x not in seen:
if x == pid:
return True
seen.add(x)
x = up.get(x)
return False

homes = {x["id"]: x for x in pages if x.get("place") in HOMES and not inside(x["id"])}
if not homes:
return []
mine = {f"{k}:{i}" for _, k, i, _ in mentions(p.get("body"))}
theirs = {}
for r in db.rows("SELECT page, target, label FROM note_link WHERE space = $s AND page IN $h", s=sid, h=list(homes)):
theirs.setdefault(r["page"], {})[r["target"]] = r["label"]
text = _words(f"{p.get('title') or ''} {plain(MENTION.sub(' ', p.get('body') or ''))[:SUGGEST_CHARS]}") # links count once, as links
out = []
for hid, h in homes.items():
links, score, why = theirs.get(hid, {}), 0, []
if f"page:{hid}" in mine:
score, why = score + 3, why + ["it links to this page"]
if f"page:{pid}" in links:
score, why = score + 2, why + ["this page links to it"]
shared = sorted(t for t in mine & set(links) if not t.startswith("page:"))
if shared:
score += len(shared)
why.append("both link " + ", ".join(links[t] for t in shared[:3]))
named = _words(h["title"]) & text
if named:
score += min(len(named), 2)
why.append("it names " + ", ".join(sorted(named)[:3]))
if score >= 2:
out.append({"page": hid, "title": h["title"], "place": h["place"], "score": score, "why": why})
out.sort(key=lambda x: (-x["score"], HOMES.index(x["place"]), x["page"]))
return out[:limit]


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
8 changes: 8 additions & 0 deletions fastapi_backend/app/schemas/notebook.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,11 @@ class NoteLinkTarget(ResponseModel):

class NoteLinkSuggestion(NoteLinkTarget):
sign: Sign = Field(description="# for a topic, @ for anything else")


class NoteHomeSuggestion(ResponseModel):
page: int = Field(description="the project or area page it could go inside")
title: str
place: Literal["project", "area"]
score: int
why: list[str] = Field(description="what ties them: links between them, links they share, words of its title")
39 changes: 39 additions & 0 deletions fastapi_backend/tests/api/test_notebook.py
Original file line number Diff line number Diff line change
Expand Up @@ -348,3 +348,42 @@ def test_link_suggestions(client, env, db, cfg):
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() == []


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

he, hv = env["he"], env["hv"]
pods = store.ns_id(db, "pods")
ada, acme = _entity(db, "Ada Lovelace", "PERSON"), _entity(db, "Acme", "ORG")
launch = _new(client, he, title="Capsid launch", place="project", body=f"@[Acme](entity:{acme['id']}) and @[Ada](entity:{ada['id']})")
health = _new(client, he, title="Health", place="area")
_new(client, he, title="Reading list", place="resource", body=f"@[Acme](entity:{acme['id']})") # not a project or area
old = _new(client, he, title="Capsid launch retro", place="archive")
homes = lambda pid, h=hv: client.get(f"/api/v1/notes/{pid}/homes", headers=h).json() # noqa: E731

# it links what the project links, and names the project's title: the project first, with why
p = _new(client, he, title="Call with Acme", body=f"About the capsid launch. @[Acme](entity:{acme['id']}) is in.")
got = homes(p["id"])
assert [(x["page"], x["place"]) for x in got] == [(launch["id"], "project")]
assert got[0]["score"] == 3 and got[0]["why"] == ["both link Acme", "it names capsid, launch"]
# a link to an area page is enough on its own
q = _new(client, he, title="Sleep", body=f"See @[Health](page:{health['id']}).")
assert [x["page"] for x in homes(q["id"])] == [health["id"]]
# one shared word isn't enough; nothing in common, nothing suggested
assert homes(_new(client, he, title="Launch party")["id"]) == []
assert homes(_new(client, he, title="Groceries", body="milk")["id"]) == []
# nested, filed as a project or area, archived, or a thing's page: none
client.post(f"/api/v1/notes/{p['id']}/move", headers=he, json={"parent": launch["id"]})
assert homes(p["id"]) == []
assert homes(launch["id"]) == [] and homes(old["id"]) == []
# a project can't be suggested as a home for a page it sits inside
client.post(f"/api/v1/notes/{launch['id']}/move", headers=he, json={"parent": q["id"]})
assert [x["page"] for x in homes(q["id"])] == [health["id"]]
# the assistant sees them when it reads the note, and moves it with update_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": q["id"]})[0])
assert out["could_go_in"] == [{"parent_id": health["id"], "title": "Health", "place": "area", "why": ["it links to this page"]}]
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 @@ -1131,6 +1131,11 @@ export type {
Highlight,
HighlightCreate,
HighlightUpdate,
HomeSuggestionsData,
HomeSuggestionsError,
HomeSuggestionsErrors,
HomeSuggestionsResponse,
HomeSuggestionsResponses,
HttpValidationError,
HubLogin,
HubLoginCreate,
Expand Down Expand Up @@ -1630,6 +1635,7 @@ export type {
NoteBacklink,
NoteCreate,
NoteHistory,
NoteHomeSuggestion,
NoteLink,
NoteLinkSuggestion,
NoteLinkTarget,
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 @@ -567,6 +567,9 @@ import type {
HideEntityData,
HideEntityErrors,
HideEntityResponses,
HomeSuggestionsData,
HomeSuggestionsErrors,
HomeSuggestionsResponses,
ImportFromSourceData,
ImportFromSourceErrors,
ImportFromSourceResponses,
Expand Down Expand Up @@ -3853,6 +3856,21 @@ export class Notes {
});
}

/**
* Home Suggestions
*
* The project or area pages a free note at the top of the tree could go inside, best first, from the links and
* words they share; moving it there is one click (POST /{pid}/move). No model is asked.
*/
public static homeSuggestions<ThrowOnError extends boolean = false>(
options: Options<HomeSuggestionsData, ThrowOnError>,
): RequestResult<HomeSuggestionsResponses, HomeSuggestionsErrors, ThrowOnError> {
return (options.client ?? client).get<HomeSuggestionsResponses, HomeSuggestionsErrors, ThrowOnError>({
url: "/api/v1/notes/{pid}/homes",
...options,
});
}

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

/**
* NoteHomeSuggestion
*/
export type NoteHomeSuggestion = {
/**
* Page
*
* the project or area page it could go inside
*/
page: number;
/**
* Title
*/
title: string;
/**
* Place
*/
place: "project" | "area";
/**
* Score
*/
score: number;
/**
* Why
*
* what ties them: links between them, links they share, words of its title
*/
why: Array<string>;
[key: string]: unknown;
};

/**
* NoteLink
*/
Expand Down Expand Up @@ -20179,6 +20210,38 @@ export type LinkSuggestionsResponses = {

export type LinkSuggestionsResponse = LinkSuggestionsResponses[keyof LinkSuggestionsResponses];

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

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

export type HomeSuggestionsError = HomeSuggestionsErrors[keyof HomeSuggestionsErrors];

export type HomeSuggestionsResponses = {
/**
* Response Notes-Home Suggestions
*
* Successful Response
*/
200: Array<NoteHomeSuggestion>;
};

export type HomeSuggestionsResponse = HomeSuggestionsResponses[keyof HomeSuggestionsResponses];

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

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

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

/** The project or area a note at the top of the tree fits under (from the links and words they share): one click
* puts it there. Found without a model; nothing moves on its own. */
export function HomeSuggestion({
pid,
version,
onMove,
busy,
}: {
pid: number;
version: string | null | undefined;
onMove: (s: NoteHomeSuggestion) => void;
busy: boolean;
}) {
const client = useApiClient();
const q = useQuery({
queryKey: ["note-homes", pid, version],
queryFn: () => data(Notes.homeSuggestions({ client, path: { pid } })),
staleTime: 30_000,
});
const best = q.data?.[0];
if (!best) return null;
return (
<button
type="button"
disabled={busy}
=> onMove(best)}
className="inline-flex items-center gap-1 rounded-pill border border-border px-2 py-0.5 text-fg-accent hover:bg-blue-surface disabled:opacity-50"
title={`It fits there: ${best.why.join("; ")}`}
>
<FolderInput className="size-3.5" />
Put inside {best.title}
</button>
);
}
Loading
Loading
0