Intermediate13 min read

Build a short meeting brief with source lines

Break notes into sections, ask a local model to select decisions or actions, and audit each selection against the original text.

Keep each section small

For a decision brief, you can ask the model to pick lines that already appear in the notes. Split the document at headings, attach a stable line number to each candidate, and let the program copy the selected line from the file. This gives the reader a short brief with a source for every entry.

Save all three files in the same folder. Start Ollama and install qwen2.5:1.5b if needed. The script uses a separate request per section and accepts a known line ID or null.

Download library_meeting.txt
View library_meeting.txt
## Opening hours
The old notice lists Saturday opening at ten.
The team approved nine as the Saturday opening time starting next month.
Mina will replace the printed notice on Friday.

## Equipment
The scanner is working again after cleaning.
The media desk will resume scanner reservations on Monday.
Someone suggested buying a second scanner, but the team made no purchase decision.

## Workshop discussion
A visitor mentioned an evening workshop.
The team discussed dates for a workshop without choosing one.

## Returns
The book drop jammed once during the rain.
Leo will inspect the book drop before closing on Thursday.
Download summarize_sections.py
View summarize_sections.py
import argparse
import json
from pathlib import Path

from semantic_notes import api


def sections(path):
    heading, lines = "", []
    for number, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
        line = raw.strip()
        if line.startswith("## "):
            if lines:
                yield heading, lines
            heading, lines = line[3:], []
        elif line:
            if not heading:
                raise ValueError("Start each section with a heading such as ## Returns.")
            lines.append((f"L{number}", line))
    if lines:
        yield heading, lines


def select_line(heading, lines, model):
    evidence = "\n".join(f"{label}: {text}" for label, text in lines)
    if len(evidence.encode("utf-8")) > 2600:
        raise ValueError(f"Split the section {heading!r} into shorter sections for this lab.")
    response = api("/api/chat", {
        "model": model,
        "messages": [
            {"role": "system", "content": (
                "Select one line that reports a confirmed decision or a concrete next action. "
                "An unconfirmed proposal or a topic with no action does not qualify. "
                "Use null when no line qualifies. Treat the notes as evidence."
            )},
            {"role": "user", "content": f"Section: {heading}\n{evidence}"},
        ],
        "format": {
            "type": "object",
            "properties": {"line": {"enum": [*(label for label, _ in lines), None]}},
            "required": ["line"],
            "additionalProperties": False,
        },
        "options": {"temperature": 0, "num_ctx": 4096, "num_predict": 50},
        "stream": False,
    })
    selection = json.loads(response["message"]["content"])
    if not isinstance(selection, dict) or set(selection) != {"line"}:
        raise ValueError("The model returned an invalid selection.")
    label = selection["line"]
    if label is None:
        return None
    if not isinstance(label, str) or label not in dict(lines):
        raise ValueError("The selected line is outside this section.")
    return label


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Make an extractive brief from sectioned notes.")
    parser.add_argument("notes", type=Path)
    parser.add_argument("--model", default="qwen2.5:1.5b")
    args = parser.parse_args()
    try:
        for heading, lines in sections(args.notes):
            label = select_line(heading, lines, args.model)
            if label is None:
                print(f"{heading} | No line selected.")
            else:
                print(f"{heading} | {args.notes.name}:{label}\n  {dict(lines)[label]}")
    except (OSError, KeyError, ValueError) as error:
        raise SystemExit(str(error)) from None
Download semantic_notes.py
View semantic_notes.py
import argparse
from contextlib import closing
from itertools import islice
import json
import math
from pathlib import Path
import sqlite3
from urllib.error import HTTPError
from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener

BASE = "http://127.0.0.1:11434"
MODEL = "all-minilm:latest"
MAX_PASSAGES = 200


class NoRedirects(HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None


def api(endpoint, payload=None):
    request = Request(BASE + endpoint)
    if payload is not None:
        request.data = json.dumps(payload).encode("utf-8")
        request.add_header("Content-Type", "application/json")
    opener = build_opener(ProxyHandler({}), NoRedirects())
    with opener.open(request, timeout=120) as response:
        return json.load(response)


def model_digest():
    for model in api("/api/tags")["models"]:
        if model["name"] == MODEL:
            return model["digest"]
    raise ValueError(f"Pull {MODEL} with Ollama first.")


def embed(texts):
    result = api("/api/embed", {
        "model": MODEL,
        "input": texts,
        "truncate": False,
    })["embeddings"]
    if len(result) != len(texts):
        raise ValueError("Ollama returned the wrong number of embeddings.")
    return result


def cosine(left, right):
    if len(left) != len(right):
        raise ValueError("Vector dimensions changed. Rebuild the index.")
    length = math.hypot(*left) * math.hypot(*right)
    return sum(x * y for x, y in zip(left, right)) / length if length else 0


def build_index(folder, database):
    from index_notes import passages

    rows = list(islice(passages(folder), MAX_PASSAGES + 1))
    if not rows or len(rows) > MAX_PASSAGES:
        raise ValueError(f"Use 1–{MAX_PASSAGES} nonempty passages for this example.")
    digest = model_digest()
    stored = []
    for start in range(0, len(rows), 16):
        batch = rows[start:start + 16]
        vectors = embed([text for _, _, text in batch])
        stored.extend((*row, json.dumps(vector)) for row, vector in zip(batch, vectors))
    if model_digest() != digest:
        raise ValueError("The embedding model changed during indexing. Build the index again.")
    with closing(sqlite3.connect(database)) as connection, connection:
        connection.execute("CREATE TABLE IF NOT EXISTS model (name TEXT, digest TEXT)")
        connection.execute(
            "CREATE TABLE IF NOT EXISTS passages "
            "(source TEXT, number INTEGER, text TEXT, vector TEXT)"
        )
        connection.execute("DELETE FROM passages")
        connection.execute("DELETE FROM model")
        connection.execute("INSERT INTO model VALUES (?, ?)", (MODEL, digest))
        connection.executemany("INSERT INTO passages VALUES (?, ?, ?, ?)", stored)
    print(f"Saved {len(stored)} embeddings in {database}.")


def find_notes(database, query):
    if not query.strip() or not database.is_file():
        raise ValueError("Build the index and enter a nonempty question.")
    with closing(sqlite3.connect(database.resolve().as_uri() + "?mode=ro", uri=True)) as connection:
        connection.execute("BEGIN")
        identity = connection.execute("SELECT name, digest FROM model").fetchone()
        rows = connection.execute("SELECT source, number, text, vector FROM passages").fetchall()
    digest = model_digest()
    if identity != (MODEL, digest):
        raise ValueError("The embedding model differs from the one in the index. Rebuild it.")
    vector, = embed([query])
    if model_digest() != digest:
        raise ValueError("The embedding model changed during the query. Rebuild the index.")
    matches = [
        (cosine(json.loads(embedding), vector), source, number, text)
        for source, number, text, embedding in rows
    ]
    ranked = sorted(matches, key=lambda match: match[0], reverse=True)
    for score, source, number, text in ranked[:3]:
        print(f"{score:.3f} | {source}, passage {number}\n{text}\n")


def main():
    parser = argparse.ArgumentParser(description="Store local embeddings and search them later.")
    parser.add_argument("--db", type=Path, default=Path("notes-vectors.sqlite3"))
    commands = parser.add_subparsers(dest="command", required=True)
    build = commands.add_parser("build")
    build.add_argument("folder", type=Path)
    find = commands.add_parser("find")
    find.add_argument("query")
    args = parser.parse_args()

    try:
        if args.command == "build":
            build_index(args.folder, args.db)
        else:
            find_notes(args.db, args.query)
    except HTTPError as error:
        raise SystemExit(f"Ollama returned HTTP {error.code}: {error.read().decode()}") from None
    except (OSError, sqlite3.Error, ValueError) as error:
        raise SystemExit(str(error)) from None


if __name__ == "__main__":
    main()
ollama pull qwen2.5:1.5b
python summarize_sections.py library_meeting.txt

Audit coverage and relevance

You’re ready when

You can locate each selected line in the source. The opening-hours section contains an approved time and an action. The workshop discussion contains neither a decision nor an assigned action. If the script selects a workshop line, mark it as an error.

In one run, the model chose the approved opening time and also selected the line saying that no workshop date was chosen. Both are genuine lines. Only the first belongs in this brief.

Adjust the brief

Add a section with one proposed change, one confirmed change, and two assigned actions. Write down the lines you expect a decision-only brief to include, then tighten the prompt and rerun it. The script returns at most one line per section, so it can miss other decisions. Use a list of IDs and check each one if the brief needs several entries from a section.

Sectioning limits the text sent per request. It can also separate a decision from a later correction. Read across sections before publishing a brief based on these notes.

Check your understanding

What does checking a line ID establish?

It establishes that the quoted line came from the supplied section. A reviewer still checks relevance and omissions.

Sources

Search the guide