File size: 7,732 Bytes
55d0eca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
"""The suggested templates in the Create report dialog: written by the AI from each post's stored ideas and topics, kept by a hash of
what it read, prepared in the background as soon as ideas are extracted, and served at once whenever the dialog opens."""

import hashlib
import logging
import uuid

from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session

from app.config import settings
from app.llm import run
from app.llm.provider import suggest_templates_lm
from app.llm.signatures import SuggestReportTemplates
from app.models import Document, Notebook, ReportTemplateCache
from app.pipeline.generate import ideas_fresh
from app.pipeline.material import max_posts
from app.pipeline.passages import notebook_docs
from app.services.jobs import create_job, pending_job

log = logging.getLogger("notestack")

SUGGEST_ITEMS = 4  # sources the AI reads to suggest templates
SUMMARY_IDEAS = 3  # a post's stored ideas shown to the AI
SUMMARY_CHARS = 350  # most characters of one post's summary
WARM_NOTEBOOKS = 30  # notebooks prepared per run (the most recently changed)
KEEP_PER_WORKSPACE = 10  # stored sets kept per workspace; the oldest go


def summary(d: Document) -> str:
    """A post as the AI sees it when suggesting templates: its title, its topics and the key ideas already extracted from it (the
    ones the Mind Constellation and the topic map are built from), a few hundred characters at most."""
    meta = d.metadata_json or {}
    ideas = [f"{i['label'].strip()}: {(i.get('note') or '').strip()}" for i in meta.get("ideas") or [] if (i.get("label") or "").strip()]
    tags = [t["name"] for t in meta.get("topics") or [] if t.get("name")][:3]
    head = d.title + (f"\nTopics: {', '.join(tags)}" if tags else "")
    return (head + "\n" + "\n".join(f"- {x}" for x in ideas[:SUMMARY_IDEAS]))[:SUMMARY_CHARS]


def stable_order(docs: list[Document]) -> list[Document]:
    """The posts in an order that belongs to the posts, not to the selection: the same posts come first whenever they are picked.
    The templates are read from the first few, so ticking or unticking any other post gives the same input, the same key, and the
    templates kept for it (the ones prepared in the background for the whole notebook) are served at once, not written again."""
    return sorted(docs, key=lambda d: hashlib.sha256(str(d.id).encode()).hexdigest())


def suggestion_items(material: dict[str, str], docs: list[Document]) -> list[str]:
    """What the AI reads: each post's stored summary when it has one (else the start of the post, as `material` holds it), or for
    chats their first exchange. At most SUGGEST_ITEMS, from the posts that come first in stable_order."""
    if not docs:
        return list(material.values())[:SUGGEST_ITEMS]
    out = []
    for d in stable_order(docs):
        item = summary(d) if ideas_fresh(d) else material.get(str(d.id))
        if item:
            out.append(item)
        if len(out) == SUGGEST_ITEMS:
            break
    return out


def cache_key(items: list[str], topic: str) -> str:
    return hashlib.sha256(("\x1f".join(items) + "\x1e" + (topic or "").strip()).encode()).hexdigest()


def _clean(out: dict) -> list[dict]:
    seen, found = set(), []
    for t in out.get("templates") or []:
        name, prompt = (t.get("name") or "").strip(), (t.get("prompt") or "").strip()
        if not name or not prompt or name.lower() in seen:
            continue
        seen.add(name.lower())
        found.append({"id": f"suggested-{len(found) + 1}", "name": name[:60],
                      "description": (t.get("description") or "").strip()[:200], "prompt": prompt[:1500]})
    return found[:4]


def suggest(db: Session, workspace_id: uuid.UUID, items: list[str], topic: str = "") -> list[dict]:
    """Four templates for these sources: the stored ones when the same sources were seen before, else written now (one AI call) and
    stored. A failed or empty answer is never stored. Never raises."""
    if not items:
        return []
    key = cache_key(items, topic)
    hit = db.scalar(select(ReportTemplateCache).where(ReportTemplateCache.workspace_id == workspace_id, ReportTemplateCache.key == key))
    if hit and hit.templates:
        log.info("report templates: kept set served workspace=%s key=%s", workspace_id, key[:10])
        return hit.templates
    log.info("report templates: no kept set, asking the model workspace=%s key=%s items=%d", workspace_id, key[:10], len(items))
    try:
        out = run.predict(SuggestReportTemplates, db=db, workspace_id=workspace_id, lm=suggest_templates_lm(), items=items,
                          topic=(topic or "").strip() or "(none)")
    except Exception:
        log.warning("template suggestions failed", exc_info=True)
        return []
    templates = _clean(out)
    if templates:
        _store(db, workspace_id, key, templates)
    return templates


def _store(db: Session, workspace_id: uuid.UUID, key: str, templates: list[dict]) -> None:
    try:
        row = db.scalar(select(ReportTemplateCache).where(ReportTemplateCache.workspace_id == workspace_id, ReportTemplateCache.key == key))
        if row:
            row.templates = templates
        else:
            db.add(ReportTemplateCache(workspace_id=workspace_id, key=key, templates=templates))
        db.commit()
    except IntegrityError:  # another request stored the same set first
        db.rollback()
        return
    old = db.scalars(select(ReportTemplateCache).where(ReportTemplateCache.workspace_id == workspace_id)
                     .order_by(ReportTemplateCache.updated_at.desc()).offset(KEEP_PER_WORKSPACE)).all()
    for row in old:
        db.delete(row)
    if old:
        db.commit()


def queue_warmup(db: Session, workspace_id: uuid.UUID) -> None:
    """Have the templates for the workspace's notebooks prepared in the background (once at a time). Called when ideas have been
    extracted and when posts join a notebook, so they are ready before anyone opens the dialog."""
    if settings.llm_api_key and not pending_job(db, workspace_id, "report_templates"):
        create_job(db, workspace_id, "report_templates", {}, max_attempts=2)


def warm_workspace(db: Session, workspace_id: uuid.UUID) -> int:
    """Write and keep the templates of each recently changed notebook for the posts its dialog starts with (the newest ones, as the
    dialog ticks them), skipping a notebook whose posts have mostly no stored ideas yet (they are prepared once the ideas land) and
    any set already stored. Returns how many were written."""
    from app.routers.videos import _excerpt, is_locked  # the dialog's own preview of a post, so the keys match

    written = 0
    for nb in db.scalars(select(Notebook).where(Notebook.workspace_id == workspace_id)
                         .order_by(Notebook.updated_at.desc()).limit(WARM_NOTEBOOKS)):
        docs = [d for d in notebook_docs(db, nb.id) if not is_locked(d)][:max_posts()]
        if not docs or sum(1 for d in docs if ideas_fresh(d)) * 2 < len(docs):
            continue
        docs.sort(key=lambda d: d.published_at or d.created_at, reverse=True)
        each = 800 if len(docs) == 1 else 400
        material = {str(d.id): "\n".join(p for p in (d.title or "", _excerpt(d.clean_text, each)) if p) for d in docs}
        items = suggestion_items(material, docs)
        key = cache_key(items, "")
        if items and not db.scalar(select(ReportTemplateCache.id).where(ReportTemplateCache.workspace_id == workspace_id,
                                                                         ReportTemplateCache.key == key)):
            written += bool(suggest(db, workspace_id, items, ""))
    return written