8000 feat(analysis): places and dates from anytopdf as hints for the graph by adeelahmad · Pull Request #229 · 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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ The backend (`fastapi_backend`) and the frontend (`nextjs-frontend`) are version

## Unreleased

- **Places and dates from anytopdf.** Turn on Settings › Analysis › Find places and dates with anytopdf (off by
default) and analysis also asks anytopdf, offline, for the places a text names, the dates it gives (with their ISO
value) and where a photo was taken (from its GPS fix, with `exiftool` installed). They become places and dates in the
graph, and the resource's Details tab lists them for the namespace's members
([anytopdf](docs/configuration.md#anytopdf)).
- **WhatsApp, Telegram, Slack and iMessage chats.** Import an exported chat (or upload a WhatsApp .txt) and Lens
reads it message by message, with who sent each one as its speaker and when it was sent, so the assistant can
answer from your real conversations. It gets the new Chat export content type
Expand Down
13 changes: 13 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,19 @@ Lens can also ask anytopdf's face and object plugins what is in a picture (`anyt
(`--plugin-sandbox strict`: no network, writing only to their own workspace, reading only the picture and the models
Lens names), and the objects plugin runs only when it's given a model, such as Lens's YOLOX file.

**Places and dates.** With `analysis.anytopdf` on (Settings › Analysis › Find places and dates with anytopdf; off by
default), analysis asks anytopdf, run here with no network and no plugins, what a recording's text and a photo hold:

- the places its text names, from anytopdf's offline gazetteer (GeoNames, CC BY 4.0), and the dates and times it
gives, with their ISO value when the text pins one down ("3 March 2026", not "next Friday");
- where a photo was taken: its GPS fix, named offline after the nearest town. anytopdf reads GPS with `exiftool`, so
without `exiftool` beside it a photo gets no place. Each photo is read once.

They are hints: the places and dates join the names the extractor finds in each line (a name the rules only knew as a
term becomes a place), and the namespace's entity setup still decides what is kept. What was found shows on the
resource's Details tab, for the namespace's members only: visitors, embeds and share links don't see where a photo was
taken. If anytopdf can't read something, analysis goes on without hints. Lens downloads anytopdf for this the same way.

Neither may reach anything while converting: Chromium goes through a proxy inside Lens that serves the page and refuses
every other request (the page also allows no scripts), and LibreOffice is given a proxy address that isn't there.
Chromium runs with its sandbox where it can, and without it as root or where the container lacks what the sandbox
Expand Down
4 changes: 3 additions & 1 deletion fastapi_backend/app/api/v1/routes/recordings.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ def retag_recordings(body: RecordingsRetag, acl: Acl, user: Writer, db: Db) -> T
def get_recording(rid: int, acl: Acl, db: Db, cfg: Cfg) -> Recording:
r = acl.recording(rid)
space = db.one("SELECT name FROM $s", s=R("space", r["space"])) or {}
d = {k: v for k, v in r.items() if k not in ("envelope", "stats", "summary", "access_parts")}
d = {k: v for k, v in r.items() if k not in ("envelope", "stats", "summary", "access_parts", "enrichment")}
a = acc.of(db, rid)
d.update(access=a["access"], open=a["open"], featured=a["featured"], access_inherited=a["inherited"])
d.update(
Expand All @@ -290,6 +290,8 @@ def get_recording(rid: int, acl: Acl, db: Db, cfg: Cfg) -> Recording:
]
d["jobs"] = jobs.list_jobs(db, recording=rid, limit=5)
d["attached_to"] = _attached_to(acl, db, r.get("attached_to"))
if r.get("enrichment") and acl.member(r): # where a photo was taken: members only
d["enrichment"] = {k: v for k, v in r["enrichment"].items() if k != "photo_fp"}
return Recording.model_validate(sign_urls(d, full=True))


Expand Down
8 changes: 7 additions & 1 deletion fastapi_backend/app/domain/analyze.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import urllib.request
from collections import Counter, defaultdict

from . import entity_map, entity_setup, graph_history, store, telemetry
from . import enrich, entity_map, entity_setup, graph_history, store, telemetry

STOP = set(
"""a about above after again against all almost also am an and any are aren as at be because been before being
Expand Down Expand Up @@ -412,6 +412,12 @@ def analyze_recording(db, cfg, rid, seg_ents=None):
for (a, sa), (b, sb) in zip(ws, ws[1:]):
tn[a + " " + b] += 1
ts[a + " " + b][sa + " " + sb] += 1
if given is None: # the places and dates anytopdf finds, where they're on (enrich.py)
for es, hints in zip(seg_ents, enrich.enrich(db, cfg, rid, segs)):
typed = {ent_key(n): t for n, t in hints}
es[:] = [(n, typed.get(ent_key(n), t) if t == "TERM" else t) for n, t in es] # a bare term anytopdf knows
have = {ent_key(n) for n, _ in es}
es.extend(h for h in hints if ent_key(h[0]) not in have)
if setup["types"] and not listed: # the namespace (or collection) keeps only some types
seg_ents = [[(n, t) for n, t in es if entity_setup.keeps(setup, t)] for es in seg_ents]
surface = {t: c.most_common(1)[0][0] for t, c in ts.items()}
Expand Down
34 changes: 32 additions & 2 deletions fastapi_backend/app/domain/anytopdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,9 +201,8 @@ def _local(cfg, src, out, scan, browser=None):
doc = pathlib.Path(tmp) / f"document{src.suffix.lower()}"
shutil.copyfile(src, doc)
made = pathlib.Path(tmp) / "out.pdf"
env = netguard.nowhere_env({**os.environ, "HOME": tmp, "ANYTOPDF_DATA_DIR": f"{tmp}/data", "TMPDIR": tmp})
env = {k: v for k, v in env.items() if not k.startswith("ANYTOPDF_") or k == "ANYTOPDF_DATA_DIR"}
argv = [exe, "--no-plugins", "--no-config", "convert", str(doc), "-o", str(made), *_args(cfg, scan)]
env = _env(tmp)
if browser: # drawn offline: the page Lens made has nothing to fetch, and the browser couldn't anyway
argv.append("--html-render")
env["ANYTOPDF_CHROME"] = browser
Expand All @@ -215,6 +214,37 @@ def _local(cfg, src, out, scan, browser=None):
shutil.move(str(made), out)


def _env(tmp):
"""No network, and no anytopdf settings but its own data folder, in `tmp`."""
env = netguard.nowhere_env({**os.environ, "HOME": tmp, "ANYTOPDF_DATA_DIR": f"{tmp}/data", "TMPDIR": tmp})
return {k: v for k, v in env.items() if not k.startswith("ANYTOPDF_") or k == "ANYTOPDF_DATA_DIR"}


def graph(cfg, src, args):
"""What anytopdf finds in the file at `src` (run here, offline, with no plugins): its document graph, {sources,
units: [{kind, visible_text, annotations: [{kind, text, confidence, attributes}]}]}, from `--dump-graph`. The PDF
it makes on the way is thrown away. Unavailable when it isn't here; ValueError when it can't read the file."""
from . import convert

exe = binary(cfg)
if not exe:
raise Unavailable("anytopdf isn't installed here")
seconds = _opts(cfg).get("convert_seconds") or 300
with tempfile.TemporaryDirectory(prefix="lens-anytopdf-") as tmp:
doc = pathlib.Path(tmp) / f"document{pathlib.Path(src).suffix.lower()}"
shutil.copyfile(src, doc)
made, dump = pathlib.Path(tmp) / "out.pdf", pathlib.Path(tmp) / "graph.json"
argv = [exe, "--no-plugins", "--no-config", "convert", str(doc), "-o", str(made), "--dump-graph", str(dump), *args]
r = convert._run(argv, seconds, env=_env(tmp), cwd=tmp)
if r.returncode != 0 or not dump.is_file():
said = convert._last_said(r.stderr or r.stdout)
raise ValueError(f"anytopdf couldn't read it ({said or f'exit {r.returncode}'})")
try:
return json.loads(dump.read_text())
except ValueError:
raise ValueError("anytopdf's document graph wasn't JSON") from None


class _NoRedirect(urllib.request.HTTPRedirectHandler):
"""A conversion node that answers with a redirect is refused: the token goes to the address set, never on."""

Expand Down
16 changes: 12 additions & 4 deletions fastapi_backend/app/domain/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -403,18 +403,26 @@ def fetch(self, cfg, m, say):


class Anytopdf(Component):
"""The anytopdf program (anytopdf.py), fetched when Settings → Documents asks for it to run here."""
"""The anytopdf program (anytopdf.py), fetched when Settings → Documents asks for it to run here, or Settings →
Analysis asks it for places and dates (enrich.py)."""

def needed(self, cfg, m):
from . import anytopdf

return anytopdf.mode(cfg) == "anytopdf" and not anytopdf.node(cfg) and bool(anytopdf.archive())
here = anytopdf.mode(cfg) == "anytopdf" and not anytopdf.node(cfg)
return (here or bool((cfg.get("analysis") or {}).get("anytopdf"))) and bool(anytopdf.archive())

def present(self, cfg):
from . import anytopdf

return bool(anytopdf.binary(cfg))

def serves(self, cfg, m):
from . import anytopdf

here = anytopdf.mode(cfg) == "anytopdf" and not anytopdf.node(cfg)
return ({"transcribe"} if here else set()) | ({"analyze"} if (cfg.get("analysis") or {}).get("anytopdf") else set())

def fetch(self, cfg, m, say):
from . import anytopdf

Expand Down Expand Up @@ -548,8 +556,8 @@ def detail(self, cfg):
Anytopdf(
"anytopdf",
"anytopdf",
"makes documents and photographed pages into searchable PDFs (documents.converter)",
steps={"transcribe"},
"makes documents and photographed pages into searchable PDFs (documents.converter), finds places and dates",
steps={"transcribe", "analyze"},
size_mb=50,
license="MIT OR Apache-2.0",
),
Expand Down
154 changes: 154 additions & 0 deletions fastapi_backend/app/domain/enrich.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
"""Places and dates anytopdf finds, as hints for analysis (analysis.anytopdf, off by default; docs/configuration.md#anytopdf).

anytopdf (anytopdf.py), run here offline with no plugins, reads two things for a recording when this is on:
- its text (its segments, run together): the places it names, found in anytopdf's offline gazetteer (GeoNames), and
the dates and times it gives, with their ISO value where the text pins one down ("3 March 2026", not "next Friday").
- a photo's own file: where it was taken, from its GPS fix, named offline (the nearest town). anytopdf reads GPS with
exiftool, so where exiftool isn't installed a photo gets no place.

The places and dates are hints: analysis (analyze.py) adds them to the names its extractor found in each line, and
the graph is still the only place entities are made, so a namespace's entity setup decides what is kept. What was
found is kept on the recording as `enrichment` ({by, place, places, dates}) for its members to see. Nothing is sent
anywhere, and a failure here never stops analysis: it is said and analysis goes on without hints.
"""

from __future__ import annotations

import logging
import os
import pathlib
import tempfile

from . import anytopdf, ingest, store

log = logging.getLogger(__name__)
R = store.R
MAX_CHARS = 1_000_000 # text read for places and dates at most
MIN_CONFIDENCE = 0.5 # a place named in text below this is left out (anytopdf scores a bare city name 0.6)
# text: places and dates only (no colours, no OCR: the text is Lens's own); a photo: its GPS fix only
TEXT_ARGS = ["--no-provenance-page", "--colors", "off", "--ocr", "off", "--location", "on"]
PHOTO_ARGS = ["--no-provenance-page", "--colors", "off", "--ocr", "off", "--no-entities", "--location", "gps"]


def enabled(cfg):
return bool((cfg.get("analysis") or {}) 10BC8 .get("anytopdf")) and bool(anytopdf.binary(cfg))


def _notes(graph):
"""Every annotation anytopdf made, from all of its units."""
return [a for u in graph.get("units") or [] for a in u.get("annotations") or []]


def _num(v):
try:
return float(v)
except (TypeError, ValueError):
return None


def _place(a):
"""A location annotation as Lens keeps it."""
at = a.get("attributes") or {}
return store.clean(
{
"name": at.get("place") or a.get("text"),
"city": at.get("city"),
"region": at.get("region"),
"country": at.get("country"),
"country_code": at.get("country_code"),
"lat": _num(at.get("latitude")),
"lon": _num(at.get("longitude")),
"km": _num(at.get("distance_km")),
"from": "gps" if at.get("source") == "gps" else "text",
"said": at.get("matched"),
}
)


def text_finds(graph):
"""From the graph of a recording's text: (places, dates), each [{...}], places named in it and dates it gives."""
places, dates = [], []
for a in _notes(graph):
at = a.get("attributes") or {}
if a.get("kind") == "location" and at.get("source") != "gps":
conf = a.get("confidence")
if conf is None or conf >= MIN_CONFIDENCE:
places.append(_place(a))
elif a.get("kind") == "timestamp" and at.get("entity") in ("date", "datetime", "time") and a.get("text"):
dates.append(store.clean({"text": a["text"], "iso": at.get("iso"), "kind": at.get("entity")}))
return places, dates


def photo_place(graph):
"""Where a photo was taken, from the graph of its file: the GPS place, or None."""
for a in _notes(graph):
if a.get("kind") == "location" and (a.get("attributes") or {}).get("source") == "gps":
return _place(a)
return None


def hints_for(segs, places, dates):
"""Per segment, [(name, type)]: each place and date found, in the lines that say it."""
out = [[] for _ in segs]
wanted = [(p.get("said") or p.get("name"), "PLACE") for p in places] + [(d["text"], "DATE") for d in dates]
for name, typ in dict.fromkeys(w for w in wanted if w[0]):
for k, s in enumerate(segs):
if name in (s.get("text") or ""):
out[k].append((name, typ))
return out


def _text_graph(cfg, segs):
text = " ".join(s.get("text") or "" for s in segs)[:MAX_CHARS] # lines of one page run on
if not text.strip():
return None
with tempfile.TemporaryDirectory(prefix="lens-enrich-") as tmp:
src = pathlib.Path(tmp) / "text.txt"
src.write_text(text, encoding="utf-8")
return anytopdf.graph(cfg, src, TEXT_ARGS)


def _photo(db, cfg, rec, kept):
"""A photo's GPS place and the fingerprint of the file it was read from: read once per file, then kept. None for
anything else, or a photo whose file isn't here."""
if rec.get("source") != "image":
return None
fp = rec.get("fp_key") or rec.get("path")
if kept.get("photo_fp") == fp and fp:
return kept.get("place"), fp
path = ingest.audio_path(db, cfg, rec)
if not path or not os.path.exists(path):
return None
return photo_place(anytopdf.graph(cfg, path, PHOTO_ARGS)), fp


def enrich(db, cfg, rid, segs, say=None):
"""Read a recording's places and dates with anytopdf, keep them on it, and return the hints for its segments
([(name, type)] per segment); [] for each when this is off or anytopdf can't read it."""
empty = [[] for _ in segs]
if not enabled(cfg):
return empty
rec = db.one("SELECT source, path, remote, space, fp_key, enrichment FROM $r", r=R("recording", rid)) or {}
kept = rec.get("enrichment") or {}
try:
g = _text_graph(cfg, segs)
places, dates = text_finds(g) if g else ([], [])
photo = _photo(db, cfg, rec, kept)
except (anytopdf.Unavailable, ValueError, OSError) as e:
log.warning("recording %s: no places or dates from anytopdf: %s", rid, e)
if say:
say(f"no places or dates from anytopdf: {e}")
return empty
place, fp = photo if photo else (None, None)
found = store.clean(
{
"by": f"anytopdf {anytopdf.VERSION}",
"place": place,
"photo_fp": fp,
"places": list({p["name"]: p for p in places}.values()) or None,
"dates": list({(d["text"], d.get("iso")): d for d in dates}.values()) or None,
"at": store.now(),
}
)
db.q("UPDATE $r SET enrichment = $e", r=R("recording", rid), e=found)
return hints_for(segs, places, dates)
3 changes: 2 additions & 1 deletion fastapi_backend/app/domain/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,8 @@ def labels():
"sample_seconds": 60,
"cross_namespace": "suggest",
},
"analysis": {"entities": "rules", "spacy_model": "en_core_web_sm", "gazetteer": []},
# anytopdf: the places and dates anytopdf (run here) finds, as hints for the extractor (enrich.py)
"analysis": {"entities": "rules", "spacy_model": "en_core_web_sm", "gazetteer": [], "anytopdf": False},
# chat_models: the models people may pick in Chat; empty: whatever the model server lists (docs/configuration.md)
"llm": {
"base_url": None,
Expand Down
34 changes: 34 additions & 0 deletions fastapi_backend/app/schemas/recordings.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,39 @@ class WebPage(ResponseModel):
how: Literal["printed", "pdf"] | None = Field(None, description="printed by Chromium, or a PDF kept as it was")


class EnrichedPlace(ResponseModel):
"""A place anytopdf found: named in the text, or where a photo was taken (its GPS fix, named offline)."""

name: str
city: str | None = None
region: str | None = None
country: str | None = None
country_code: str | None = None
lat: float | None = None
lon: float | None = None
km: float | None = Field(None, description="a GPS fix: how far it is from the named place")
from_: Literal["gps", "text"] = Field("text", alias="from")
said: str | None = Field(None, description="named in the text: the words that name it")


class EnrichedDate(ResponseModel):
"""A date or time the text gives."""

text: str = Field(description="as the text says it")
iso: str | None = Field(None, description="its ISO 8601 value, when the text pins one down")
kind: Literal["date", "datetime", "time"] | None = None


class Enrichment(ResponseModel):
"""The places and dates anytopdf found in a recording (analysis.anytopdf); members of its namespace only."""

by: str = Field(description="anytopdf and its version")
place: EnrichedPlace | None = Field(None, description="a photo: where it was taken")
places: list[EnrichedPlace] = Field(default_factory=list, description="places its text names")
dates: list[EnrichedDate] = Field(default_factory=list)
at: str | None = Field(None, description="when they were found")


class AttachedTo(ResponseModel):
"""The email a resource was attached to (it's one of that email's files too)."""

Expand Down Expand Up @@ -174,6 +207,7 @@ class Recording(ResponseModel):
rendition: Rendition | None = Field(None, description="a document that isn't a PDF: how the PDF it's read from was made")
attached_to: AttachedTo | None = Field(None, description="an email's attachment made a resource of its own: that email")
web: WebPage | None = Field(None, description="a web page captured as a document")
enrichment: Enrichment | None = Field(None, description="the places and dates anytopdf found (analysis.anytopdf)")


class Page(ResponseModel):
Expand Down
Loading
Loading
0