"""FlowDeck — Agent context builder (v4.10.0). Collects a compact, permission-filtered snapshot of the active workspace so the LLM can reason about real entities (collections, pages, Gitea issues) without touching the database directly. """ from __future__ import annotations import json from app.db import get_conn class ContextBuilder: """Builds the textual context that accompanies each agent run.""" def __init__(self, user_id: int, workspace_id: int | None = None): self.user_id = user_id self.workspace_id = workspace_id def build(self, *, mentions: list[str] | None = None, files: list[dict] | None = None, include_collections: bool = True) -> str: """Return a compact Markdown-ish snapshot of the workspace context.""" sections: list[str] = [] if include_collections: sections.append(self._collections_context()) sections.append(self._pages_context()) if mentions: sections.append(self._mentions_context(mentions)) if files: sections.append(self._files_context(files)) return "\n\n".join(s for s in sections if s) # ── Internals ── def _collections_context(self) -> str: with get_conn() as conn: rows = conn.execute( "SELECT id, name, icon, is_locked, schema_json FROM collections ORDER BY name" ).fetchall() if not rows: return "## Collections\n(no collections yet)" lines = ["## Collections"] lines.append("Collection IDs: " + ", ".join(str(r["id"]) for r in rows)) for r in rows: props = json.loads(r["schema_json"]) if r["schema_json"] else [] schema = ", ".join(p if isinstance(p, str) else p.get("name", "?") for p in props) or "none" lock = " [LOCKED]" if r["is_locked"] else "" lines.append(f"- #{r['id']} {r['icon']} **{r['name']}** (schema: {schema}){lock}") return "\n".join(lines) def _pages_context(self, limit: int = 40) -> str: with get_conn() as conn: rows = conn.execute( "SELECT id, collection_id, title, property_values_json " "FROM collection_pages ORDER BY updated_at DESC LIMIT ?", (limit,), ).fetchall() if not rows: return "## Pages\n(no pages yet)" lines = ["## Recent pages"] for r in rows: props = json.loads(r["property_values_json"]) if r["property_values_json"] else {} summary = ", ".join(str(v) for v in props.values() if v) if props else "" lines.append(f"- page #{r['id']} in collection #{r['collection_id']}: **{r['title']}**{(' — ' + summary) if summary else ''}") return "\n".join(lines) def _mentions_context(self, mentions: list[str]) -> str: """Resolve @collection:x / @page:y / @repo:o/r mentions to entity snapshots.""" lines = ["## Mentioned context"] for m in mentions: if m.startswith("collection:"): cid = m.split(":", 1)[1] lines.append(self._single_collection(cid)) elif m.startswith("page:"): pid = m.split(":", 1)[1] lines.append(self._single_page(pid)) elif m.startswith("repo:"): lines.append(f"- @repo: {m.split(':', 1)[1]} (Gitea issues available via read_gitea_issues)") elif m == "ws": lines.append("- @ws: full workspace context included above") return "\n".join(lines) def _single_collection(self, cid: str) -> str: with get_conn() as conn: row = conn.execute("SELECT id, name, icon, schema_json FROM collections WHERE id=?", (cid,)).fetchone() if not row: return f"- collection #{cid}: not found" return f"- collection #{row['id']} {row['icon']} **{row['name']}**" def _single_page(self, pid: str) -> str: with get_conn() as conn: row = conn.execute("SELECT id, title, property_values_json FROM collection_pages WHERE id=?", (pid,)).fetchone() if not row: return f"- page #{pid}: not found" props = json.loads(row["property_values_json"]) if row["property_values_json"] else {} return f"- page #{row['id']} **{row['title']}** props={json.dumps(props, ensure_ascii=False)}" def _files_context(self, files: list[dict]) -> str: return "## Attached files\n" + "\n".join( f"- {f.get('name', 'file')} ({f.get('size', '?')} bytes)" for f in files )