secondbrain-py 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- brain/__init__.py +0 -0
- brain/__main__.py +18 -0
- brain/_capture_command.py +445 -0
- brain/_compose.py +52 -0
- brain/activity.py +206 -0
- brain/ask.py +631 -0
- brain/audio.py +591 -0
- brain/backfill/__init__.py +12 -0
- brain/backfill/search_extras.py +141 -0
- brain/backfill/source_rows.py +101 -0
- brain/bin/__init__.py +1 -0
- brain/bin/_launcher.py +107 -0
- brain/bin/down.py +8 -0
- brain/bin/launchd.py +268 -0
- brain/bin/monitor.py +570 -0
- brain/bin/rebuild.py +8 -0
- brain/bin/status.py +8 -0
- brain/bin/up.py +8 -0
- brain/brief.py +272 -0
- brain/capture.py +49 -0
- brain/chat.py +293 -0
- brain/cli.py +9760 -0
- brain/cli_claude.py +81 -0
- brain/cli_connect.py +285 -0
- brain/cli_demo.py +266 -0
- brain/config.py +1949 -0
- brain/connect.py +925 -0
- brain/db.py +540 -0
- brain/demo/__init__.py +452 -0
- brain/demo/corpus/manifest.json +403 -0
- brain/demo/embedder.py +74 -0
- brain/durations.py +84 -0
- brain/edit_session.py +156 -0
- brain/editor.py +67 -0
- brain/elicit/__init__.py +16 -0
- brain/elicit/detectors.py +250 -0
- brain/elicit/drafter.py +70 -0
- brain/elicit/queue.py +220 -0
- brain/elicit/schema.py +48 -0
- brain/elicit/session.py +445 -0
- brain/embedding_targets.py +54 -0
- brain/embeddings.py +424 -0
- brain/enrichment.py +808 -0
- brain/errors.py +357 -0
- brain/eval/__init__.py +129 -0
- brain/eval/answer_eval.py +281 -0
- brain/eval/baseline.py +265 -0
- brain/eval/concept_extraction.py +378 -0
- brain/eval/corpus.py +152 -0
- brain/eval/errors.py +19 -0
- brain/eval/graph_baseline.py +226 -0
- brain/eval/graph_retrieval.py +202 -0
- brain/eval/graph_runner.py +319 -0
- brain/eval/metrics.py +101 -0
- brain/eval/runner.py +223 -0
- brain/format.py +783 -0
- brain/gaps.py +390 -0
- brain/graph_rag/__init__.py +94 -0
- brain/graph_rag/_retrieval_common.py +113 -0
- brain/graph_rag/aggregates.py +303 -0
- brain/graph_rag/aliases/__init__.py +583 -0
- brain/graph_rag/backends/__init__.py +10 -0
- brain/graph_rag/backends/_age_helpers.py +473 -0
- brain/graph_rag/backends/age.py +782 -0
- brain/graph_rag/backends/base.py +272 -0
- brain/graph_rag/build.py +344 -0
- brain/graph_rag/communities.py +644 -0
- brain/graph_rag/communities_summary.py +437 -0
- brain/graph_rag/concepts.py +202 -0
- brain/graph_rag/cooccur.py +193 -0
- brain/graph_rag/cross_type.py +312 -0
- brain/graph_rag/extract.py +885 -0
- brain/graph_rag/fuse.py +371 -0
- brain/graph_rag/global_.py +412 -0
- brain/graph_rag/grouping.py +372 -0
- brain/graph_rag/person_resolver.py +167 -0
- brain/graph_rag/reconcile.py +792 -0
- brain/graph_rag/relational.py +353 -0
- brain/graph_rag/retrieve.py +526 -0
- brain/graph_rag/router.py +288 -0
- brain/graph_rag/schema.py +320 -0
- brain/graph_rag/sync.py +237 -0
- brain/graph_rag/tenancy.py +43 -0
- brain/graph_rag/themes.py +501 -0
- brain/graph_rag/weighting.py +202 -0
- brain/ingest/__init__.py +1926 -0
- brain/ingest/chunker.py +249 -0
- brain/ingest/docx.py +40 -0
- brain/ingest/gmail.py +621 -0
- brain/ingest/markdown.py +37 -0
- brain/ingest/pdf.py +61 -0
- brain/ingest/stdin.py +22 -0
- brain/ingest/sub_tokens.py +91 -0
- brain/ingest/text.py +16 -0
- brain/interactions.py +205 -0
- brain/maintenance.py +355 -0
- brain/mcp_server.py +3405 -0
- brain/migrations/001_init.sql +43 -0
- brain/migrations/002_qwen3_embedding.sql +17 -0
- brain/migrations/003_vault_model.sql +41 -0
- brain/migrations/004_relax_content_hash_uniqueness.sql +18 -0
- brain/migrations/005_derived_links.sql +67 -0
- brain/migrations/006_dedup_file_by_source_path.sql +25 -0
- brain/migrations/007_email_thread_and_draft.sql +15 -0
- brain/migrations/008_gmail_thread_unique.sql +11 -0
- brain/migrations/009_chunks_weighted_tsv.sql +28 -0
- brain/migrations/010_interactions.sql +30 -0
- brain/migrations/011_documents_summary.sql +23 -0
- brain/migrations/012_graphrag.sql +171 -0
- brain/migrations/013_graphrag_communities.sql +125 -0
- brain/migrations/014_graphrag_community_summary_hash.sql +33 -0
- brain/migrations/015_interactions_graph_targets.sql +89 -0
- brain/migrations/016_index_hygiene.sql +61 -0
- brain/migrations/017_elicit.sql +30 -0
- brain/migrations/018_review_gap_signal_kinds.sql +40 -0
- brain/migrations/019_search_queries.sql +35 -0
- brain/migrations/020_link_suggestions.sql +40 -0
- brain/migrations/021_timeline_doc_date.sql +34 -0
- brain/migrations/022_link_suggestions_undirected.sql +84 -0
- brain/migrations/023_search_queries_fts_count.sql +28 -0
- brain/quartz_overrides/__init__.py +8 -0
- brain/quartz_overrides/quartz/bootstrap-cli.mjs +65 -0
- brain/quartz_overrides/quartz/build.ts +568 -0
- brain/quartz_overrides/quartz/cli/args.js +152 -0
- brain/quartz_overrides/quartz/cli/build_partial_handler.js +544 -0
- brain/quartz_overrides/quartz/cli/handlers.js +636 -0
- brain/quartz_overrides/quartz/components/CommandPalette.tsx +172 -0
- brain/quartz_overrides/quartz/components/Explorer.tsx +198 -0
- brain/quartz_overrides/quartz/components/Footer.tsx +27 -0
- brain/quartz_overrides/quartz/components/Graph.tsx +468 -0
- brain/quartz_overrides/quartz/components/PageTitle.tsx +72 -0
- brain/quartz_overrides/quartz/components/RelatedDocs.tsx +38 -0
- brain/quartz_overrides/quartz/components/Search.tsx +161 -0
- brain/quartz_overrides/quartz/components/SummaryLede.tsx +72 -0
- brain/quartz_overrides/quartz/components/index.ts +92 -0
- brain/quartz_overrides/quartz/components/pages/TagContent.tsx +272 -0
- brain/quartz_overrides/quartz/components/scripts/commandPalette.inline.ts +665 -0
- brain/quartz_overrides/quartz/components/scripts/explorer.inline.ts +768 -0
- brain/quartz_overrides/quartz/components/scripts/graph.inline.ts +2302 -0
- brain/quartz_overrides/quartz/components/scripts/relatedDocs.inline.ts +163 -0
- brain/quartz_overrides/quartz/components/scripts/search.inline.ts +1011 -0
- brain/quartz_overrides/quartz/plugins/emitters/contentIndex.ts +546 -0
- brain/quartz_overrides/quartz/plugins/transformers/codeCopy.ts +94 -0
- brain/quartz_overrides/quartz/plugins/transformers/derivedFenceMark.ts +302 -0
- brain/quartz_overrides/quartz/plugins/transformers/emailThread.ts +148 -0
- brain/quartz_overrides/quartz/plugins/transformers/emptyDoorFilter.ts +213 -0
- brain/quartz_overrides/quartz/plugins/transformers/index.ts +114 -0
- brain/quartz_overrides/quartz/plugins/transformers/linkKindMark.ts +205 -0
- brain/quartz_overrides/quartz/plugins/transformers/linkSourceTag.ts +104 -0
- brain/quartz_overrides/quartz/plugins/transformers/relativeDate.ts +100 -0
- brain/quartz_overrides/quartz/plugins/transformers/reloadSignal.ts +131 -0
- brain/quartz_overrides/quartz/processors/parse.ts +371 -0
- brain/quartz_overrides/quartz/processors/parser_cache.ts +78 -0
- brain/quartz_overrides/quartz/static/brain-logo-dark.png +0 -0
- brain/quartz_overrides/quartz/static/brain-logo-light.png +0 -0
- brain/quartz_overrides/quartz/static/codeCopy.js +196 -0
- brain/quartz_overrides/quartz/static/emailThread.js +334 -0
- brain/quartz_overrides/quartz/static/favicon.ico +0 -0
- brain/quartz_overrides/quartz/static/icon.png +0 -0
- brain/quartz_overrides/quartz/static/linkSourceTag.js +104 -0
- brain/quartz_overrides/quartz/static/relativeDate.js +142 -0
- brain/quartz_overrides/quartz/static/reload.js +168 -0
- brain/quartz_overrides/quartz/styles/brain/_article.scss +252 -0
- brain/quartz_overrides/quartz/styles/brain/_atmosphere.scss +113 -0
- brain/quartz_overrides/quartz/styles/brain/_callouts.scss +180 -0
- brain/quartz_overrides/quartz/styles/brain/_cmdk.scss +7 -0
- brain/quartz_overrides/quartz/styles/brain/_code.scss +208 -0
- brain/quartz_overrides/quartz/styles/brain/_command_palette.scss +369 -0
- brain/quartz_overrides/quartz/styles/brain/_email_thread.scss +228 -0
- brain/quartz_overrides/quartz/styles/brain/_explorer.scss +142 -0
- brain/quartz_overrides/quartz/styles/brain/_home.scss +182 -0
- brain/quartz_overrides/quartz/styles/brain/_links.scss +322 -0
- brain/quartz_overrides/quartz/styles/brain/_marginalia.scss +117 -0
- brain/quartz_overrides/quartz/styles/brain/_motion.scss +175 -0
- brain/quartz_overrides/quartz/styles/brain/_people_hub.scss +100 -0
- brain/quartz_overrides/quartz/styles/brain/_related_docs.scss +137 -0
- brain/quartz_overrides/quartz/styles/brain/_search.scss +252 -0
- brain/quartz_overrides/quartz/styles/brain/_sidebar.scss +468 -0
- brain/quartz_overrides/quartz/styles/brain/_summary_lede.scss +56 -0
- brain/quartz_overrides/quartz/styles/brain/_surface.scss +43 -0
- brain/quartz_overrides/quartz/styles/brain/_tag_content.scss +118 -0
- brain/quartz_overrides/quartz/styles/brain/_tokens.scss +197 -0
- brain/quartz_overrides/quartz/styles/brain/_typography.scss +92 -0
- brain/quartz_overrides/quartz/styles/custom.scss +89 -0
- brain/quartz_overrides/quartz/styles/graph.scss +505 -0
- brain/quartz_overrides/quartz/util/ctx.ts +92 -0
- brain/quartz_overrides/quartz/util/fastpath_manifest.ts +608 -0
- brain/quartz_overrides/quartz/util/path.ts +358 -0
- brain/quartz_overrides/quartz/util/sourceIcons.ts +55 -0
- brain/quartz_overrides/quartz.config.ts +270 -0
- brain/quartz_overrides/quartz.layout.ts +314 -0
- brain/queries.py +1188 -0
- brain/rank_fusion.py +8 -0
- brain/resurface.py +210 -0
- brain/review/__init__.py +26 -0
- brain/review/emit.py +27 -0
- brain/review/queries.py +436 -0
- brain/review/render.py +196 -0
- brain/review/scans.py +355 -0
- brain/review/weekly.py +413 -0
- brain/search.py +704 -0
- brain/set_similarity.py +15 -0
- brain/setup.py +1205 -0
- brain/tags.py +56 -0
- brain/templates/Caddyfile.j2 +9 -0
- brain/templates/__init__.py +1 -0
- brain/templates/bin/__init__.py +1 -0
- brain/templates/bin/_brain-brief-fg.sh +25 -0
- brain/templates/bin/_brain-build-fg.sh +53 -0
- brain/templates/bin/_brain-watcher-fg.sh +65 -0
- brain/templates/bin/brain-down.sh +89 -0
- brain/templates/bin/brain-status.sh +83 -0
- brain/templates/bin/brain-up.sh +221 -0
- brain/templates/docker/age/Dockerfile +79 -0
- brain/templates/docker-compose.stock.yml.j2 +26 -0
- brain/templates/docker-compose.yml.j2 +34 -0
- brain/templates/env.example +190 -0
- brain/templates/launchd/__init__.py +1 -0
- brain/templates/launchd/com.brain.brief.plist.j2 +45 -0
- brain/templates/launchd/com.brain.build.plist.j2 +46 -0
- brain/templates/launchd/com.brain.watcher.plist.j2 +46 -0
- brain/templates/skill/SKILL.md +63 -0
- brain/templates/skill/__init__.py +1 -0
- brain/timeline.py +834 -0
- brain/todo.py +124 -0
- brain/uninstall.py +185 -0
- brain/vault/__init__.py +115 -0
- brain/vault/_atomic.py +25 -0
- brain/vault/daily_index.py +228 -0
- brain/vault/derived_links/__init__.py +50 -0
- brain/vault/derived_links/directory.py +683 -0
- brain/vault/derived_links/fence.py +408 -0
- brain/vault/derived_links/gws.py +64 -0
- brain/vault/derived_links/participants.py +143 -0
- brain/vault/derived_links/pass_runner.py +362 -0
- brain/vault/derived_links/rules.py +137 -0
- brain/vault/export.py +683 -0
- brain/vault/frontmatter.py +165 -0
- brain/vault/graph.py +620 -0
- brain/vault/graph_format.py +388 -0
- brain/vault/link_rewrite.py +235 -0
- brain/vault/links.py +260 -0
- brain/vault/note_builder.py +211 -0
- brain/vault/paths.py +55 -0
- brain/vault/quartz_overlay.py +236 -0
- brain/vault/rename.py +591 -0
- brain/vault/resolver.py +304 -0
- brain/vault/slug.py +127 -0
- brain/vault/sync.py +1513 -0
- brain/vault/sync_summaries.py +264 -0
- brain/vault/templates.py +145 -0
- brain/vault/watch.py +1052 -0
- brain/wiki/__init__.py +6 -0
- brain/wiki/_github_slugger.py +76 -0
- brain/wiki/_person_name.py +314 -0
- brain/wiki/build_homepage.py +541 -0
- brain/wiki/build_partial.py +273 -0
- brain/wiki/build_people.py +934 -0
- brain/wiki/build_related.py +758 -0
- brain/wiki/build_swap.py +585 -0
- brain/wiki/build_watcher.py +975 -0
- brain/wiki/edit_classifier.py +215 -0
- brain/wiki/errors.py +10 -0
- brain/wiki/fastpath_manifest.py +475 -0
- brain/wiki/fastpath_state.py +174 -0
- brain/wiki/install.py +296 -0
- brain/wiki/slug.py +111 -0
- secondbrain_py-0.2.1.dist-info/METADATA +195 -0
- secondbrain_py-0.2.1.dist-info/RECORD +273 -0
- secondbrain_py-0.2.1.dist-info/WHEEL +5 -0
- secondbrain_py-0.2.1.dist-info/entry_points.txt +11 -0
- secondbrain_py-0.2.1.dist-info/licenses/LICENSE +21 -0
- secondbrain_py-0.2.1.dist-info/top_level.txt +1 -0
brain/brief.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Proactive daily-digest assembler (``brain brief``).
|
|
2
|
+
|
|
3
|
+
Single responsibility: assemble :class:`BriefData` from the DB and optionally
|
|
4
|
+
synthesize next-step suggestions. No CLI, no MCP, no terminal formatting — the
|
|
5
|
+
callers (CLI + MCP) format the result. Reads recent captures (via
|
|
6
|
+
:mod:`brain.activity`), open action items (via :mod:`brain.todo`), and pinned
|
|
7
|
+
docs from the interactions log.
|
|
8
|
+
|
|
9
|
+
The LLM leg goes through :func:`brain.chat.chat_json` — NOT
|
|
10
|
+
:mod:`brain.enrichment` — so this module never imports the enricher (which would
|
|
11
|
+
create an ``enrichment → brief → enrichment`` import cycle). Only titles + todo
|
|
12
|
+
texts are forwarded to the model; document bodies never leave the DB.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from datetime import date, datetime
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import psycopg
|
|
23
|
+
|
|
24
|
+
from . import chat
|
|
25
|
+
from .activity import recent_captures
|
|
26
|
+
from .config import Config
|
|
27
|
+
from .errors import EnrichmentError
|
|
28
|
+
from .queries import DocumentRow
|
|
29
|
+
from .todo import TodoRow, iter_action_item_docs
|
|
30
|
+
from .vault.frontmatter import dump_frontmatter
|
|
31
|
+
|
|
32
|
+
_logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
# Cap on suggestions requested from the model.
|
|
35
|
+
_MAX_SUGGESTIONS = 5
|
|
36
|
+
# Cap on prompt context lines (titles / todo texts) to keep the prompt bounded.
|
|
37
|
+
_MAX_PROMPT_ITEMS = 20
|
|
38
|
+
|
|
39
|
+
_SUGGEST_SYSTEM = (
|
|
40
|
+
"You are a focused assistant proposing the user's next concrete steps for "
|
|
41
|
+
"the day. You are given recent capture titles and open action items (titles "
|
|
42
|
+
"and text only — no document bodies). Propose up to 5 short, specific, "
|
|
43
|
+
"actionable next steps grounded in that context. Return ONLY valid JSON:\n"
|
|
44
|
+
'{"suggestions": ["step 1", "step 2"]}\n'
|
|
45
|
+
"Each suggestion is one imperative sentence. Never invent facts not implied "
|
|
46
|
+
"by the inputs; return fewer suggestions (or an empty list) rather than "
|
|
47
|
+
"padding."
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class PinnedDoc:
|
|
53
|
+
"""One pinned document (most-recent ``action='pinned'`` interaction)."""
|
|
54
|
+
|
|
55
|
+
document_id: str
|
|
56
|
+
title: str
|
|
57
|
+
pinned_at: datetime
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class BriefData:
|
|
62
|
+
"""Assembled daily-brief payload. Pure value object; callers format it."""
|
|
63
|
+
|
|
64
|
+
date: date
|
|
65
|
+
captures: list[DocumentRow]
|
|
66
|
+
open_todos: list[TodoRow]
|
|
67
|
+
pinned: list[PinnedDoc]
|
|
68
|
+
suggestions: list[str]
|
|
69
|
+
|
|
70
|
+
def to_dict(self) -> dict[str, Any]:
|
|
71
|
+
"""Serialize to the wire shape shared by ``brain brief --json`` + MCP."""
|
|
72
|
+
return {
|
|
73
|
+
"date": self.date.isoformat(),
|
|
74
|
+
"captures": [
|
|
75
|
+
{
|
|
76
|
+
"id": doc.id,
|
|
77
|
+
"title": doc.title,
|
|
78
|
+
"source": doc.source_kind,
|
|
79
|
+
"ingested_at": (
|
|
80
|
+
doc.ingested_at.isoformat() if doc.ingested_at else None
|
|
81
|
+
),
|
|
82
|
+
}
|
|
83
|
+
for doc in self.captures
|
|
84
|
+
],
|
|
85
|
+
"open_todos": [
|
|
86
|
+
{
|
|
87
|
+
"document_id": row.document_id,
|
|
88
|
+
"text": row.text,
|
|
89
|
+
"document_title": row.document_title,
|
|
90
|
+
"ingested_at": (
|
|
91
|
+
row.ingested_at.isoformat() if row.ingested_at else None
|
|
92
|
+
),
|
|
93
|
+
}
|
|
94
|
+
for row in self.open_todos
|
|
95
|
+
],
|
|
96
|
+
"pinned": [
|
|
97
|
+
{
|
|
98
|
+
"id": pin.document_id,
|
|
99
|
+
"title": pin.title,
|
|
100
|
+
"pinned_at": pin.pinned_at.isoformat(),
|
|
101
|
+
}
|
|
102
|
+
for pin in self.pinned
|
|
103
|
+
],
|
|
104
|
+
"suggestions": self.suggestions,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def assemble_brief(
|
|
109
|
+
conn: psycopg.Connection[Any],
|
|
110
|
+
cfg: Config,
|
|
111
|
+
*,
|
|
112
|
+
since_hours: int,
|
|
113
|
+
todo_since_days: int,
|
|
114
|
+
on_date: date,
|
|
115
|
+
) -> BriefData:
|
|
116
|
+
"""Assemble the brief for ``on_date`` (no LLM — ``suggestions`` is empty).
|
|
117
|
+
|
|
118
|
+
``since_hours`` bounds the recent-captures window; ``todo_since_days`` bounds
|
|
119
|
+
the open-action-item window; ``on_date`` is the header date (the caller
|
|
120
|
+
passes today so this stays deterministic under test). Suggestions are filled
|
|
121
|
+
separately by :func:`suggest_next_steps`.
|
|
122
|
+
"""
|
|
123
|
+
captures = recent_captures(
|
|
124
|
+
conn, since_hours=since_hours, limit=cfg.brief_capture_limit
|
|
125
|
+
)
|
|
126
|
+
open_todos = list(
|
|
127
|
+
iter_action_item_docs(
|
|
128
|
+
conn, since_days=todo_since_days, include_closed=False
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
pinned = _pinned_docs(conn, limit=cfg.brief_pin_limit)
|
|
132
|
+
return BriefData(
|
|
133
|
+
date=on_date,
|
|
134
|
+
captures=captures,
|
|
135
|
+
open_todos=open_todos,
|
|
136
|
+
pinned=pinned,
|
|
137
|
+
suggestions=[],
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _pinned_docs(conn: psycopg.Connection[Any], *, limit: int) -> list[PinnedDoc]:
|
|
142
|
+
"""Return the most-recently pinned docs (one row per doc, newest first).
|
|
143
|
+
|
|
144
|
+
A doc may be pinned more than once; ``MAX(at)`` collapses to the latest pin
|
|
145
|
+
timestamp per doc. ``DISTINCT ON`` is invalid alongside ``GROUP BY``, so the
|
|
146
|
+
grouped subquery feeds the title join.
|
|
147
|
+
"""
|
|
148
|
+
rows = conn.execute(
|
|
149
|
+
"""
|
|
150
|
+
SELECT d.id::text, d.title, p.pinned_at
|
|
151
|
+
FROM (
|
|
152
|
+
SELECT document_id, MAX(at) AS pinned_at
|
|
153
|
+
FROM interactions
|
|
154
|
+
WHERE action = 'pinned' AND document_id IS NOT NULL
|
|
155
|
+
GROUP BY document_id
|
|
156
|
+
ORDER BY MAX(at) DESC
|
|
157
|
+
LIMIT %s
|
|
158
|
+
) p
|
|
159
|
+
JOIN documents d ON d.id = p.document_id
|
|
160
|
+
ORDER BY p.pinned_at DESC, d.id
|
|
161
|
+
""",
|
|
162
|
+
(limit,),
|
|
163
|
+
).fetchall()
|
|
164
|
+
return [
|
|
165
|
+
PinnedDoc(document_id=r[0], title=r[1], pinned_at=r[2]) for r in rows
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _build_suggest_prompt(brief: BriefData) -> str:
|
|
170
|
+
"""Compose the suggestion prompt from titles + todo texts only (no bodies)."""
|
|
171
|
+
parts = [_SUGGEST_SYSTEM, "", "RECENT CAPTURES:"]
|
|
172
|
+
if brief.captures:
|
|
173
|
+
for doc in brief.captures[:_MAX_PROMPT_ITEMS]:
|
|
174
|
+
parts.append(f"- {doc.title}")
|
|
175
|
+
else:
|
|
176
|
+
parts.append("- (none)")
|
|
177
|
+
parts.append("")
|
|
178
|
+
parts.append("OPEN ACTION ITEMS:")
|
|
179
|
+
if brief.open_todos:
|
|
180
|
+
for row in brief.open_todos[:_MAX_PROMPT_ITEMS]:
|
|
181
|
+
parts.append(f"- {row.text}")
|
|
182
|
+
else:
|
|
183
|
+
parts.append("- (none)")
|
|
184
|
+
return "\n".join(parts)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def suggest_next_steps(brief: BriefData, cfg: Config) -> list[str]:
|
|
188
|
+
"""Return up to 5 LLM-proposed next steps, or ``[]`` if Ollama is unavailable.
|
|
189
|
+
|
|
190
|
+
Assembles the prompt entirely from capture titles + todo texts and calls
|
|
191
|
+
:func:`brain.chat.chat_json`. Best-effort: on any
|
|
192
|
+
:class:`~brain.errors.EnrichmentError` (Ollama unavailable OR two malformed
|
|
193
|
+
JSON / schema-violating responses — ``OllamaUnavailable`` is a subclass) it
|
|
194
|
+
logs a warning and returns ``[]`` so the brief always renders. Non-string
|
|
195
|
+
entries from the model are dropped; the list is capped at five.
|
|
196
|
+
"""
|
|
197
|
+
prompt = _build_suggest_prompt(brief)
|
|
198
|
+
try:
|
|
199
|
+
body = chat.chat_json(prompt, schema={"suggestions": "list"}, cfg=cfg)
|
|
200
|
+
except EnrichmentError as exc:
|
|
201
|
+
# OllamaUnavailable is an EnrichmentError subclass, so this single clause
|
|
202
|
+
# covers transport failure AND malformed-JSON-twice — never fail the brief.
|
|
203
|
+
_logger.warning(
|
|
204
|
+
"suggest_next_steps: synthesis failed (%s); returning no suggestions",
|
|
205
|
+
exc,
|
|
206
|
+
)
|
|
207
|
+
return []
|
|
208
|
+
raw = body.get("suggestions")
|
|
209
|
+
if not isinstance(raw, list):
|
|
210
|
+
return []
|
|
211
|
+
suggestions = [s.strip() for s in raw if isinstance(s, str) and s.strip()]
|
|
212
|
+
return suggestions[:_MAX_SUGGESTIONS]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def write_brief_to_vault(vault_path: Path, on_date: date, brief: BriefData) -> Path:
|
|
216
|
+
"""Write the brief to ``<vault>/daily/<YYYY>/<date>-brief.md`` (read-only note).
|
|
217
|
+
|
|
218
|
+
A separate filename from ``brain daily``'s ``<date>.md`` so the two never
|
|
219
|
+
collide. The file is NOT ingested into Postgres — it is a rendered digest,
|
|
220
|
+
not a corpus document, so it creates no embedding churn. Surfaces titles +
|
|
221
|
+
todo texts only; never document bodies.
|
|
222
|
+
"""
|
|
223
|
+
year_folder = f"{on_date.year:04d}"
|
|
224
|
+
target = vault_path / "daily" / year_folder / f"{on_date.isoformat()}-brief.md"
|
|
225
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
226
|
+
fields = {
|
|
227
|
+
"title": f"Brain Brief · {on_date.isoformat()}",
|
|
228
|
+
"date": on_date.isoformat(),
|
|
229
|
+
"kind": "brief",
|
|
230
|
+
"tags": ["brief", "daily"],
|
|
231
|
+
}
|
|
232
|
+
target.write_text(
|
|
233
|
+
dump_frontmatter(fields, _render_brief_body(brief)), encoding="utf-8"
|
|
234
|
+
)
|
|
235
|
+
return target
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _render_brief_body(brief: BriefData) -> str:
|
|
239
|
+
"""Render the brief's Markdown body (titles + todo texts only)."""
|
|
240
|
+
lines = [
|
|
241
|
+
f"# Brain Brief · {brief.date.isoformat()}",
|
|
242
|
+
"",
|
|
243
|
+
"## Recent captures",
|
|
244
|
+
"",
|
|
245
|
+
]
|
|
246
|
+
if brief.captures:
|
|
247
|
+
for doc in brief.captures:
|
|
248
|
+
kind = doc.source_kind or "manual"
|
|
249
|
+
lines.append(f"- [{kind}] {doc.title}")
|
|
250
|
+
else:
|
|
251
|
+
lines.append("_No recent captures._")
|
|
252
|
+
|
|
253
|
+
lines += ["", "## Open action items", ""]
|
|
254
|
+
if brief.open_todos:
|
|
255
|
+
for row in brief.open_todos:
|
|
256
|
+
lines.append(f"- [ ] {row.text}")
|
|
257
|
+
else:
|
|
258
|
+
lines.append("_No open action items._")
|
|
259
|
+
|
|
260
|
+
lines += ["", "## Pinned / follow-up docs", ""]
|
|
261
|
+
if brief.pinned:
|
|
262
|
+
for pin in brief.pinned:
|
|
263
|
+
lines.append(f"- {pin.title}")
|
|
264
|
+
else:
|
|
265
|
+
lines.append("_No pinned docs._")
|
|
266
|
+
|
|
267
|
+
if brief.suggestions:
|
|
268
|
+
lines += ["", "## Suggested next steps", ""]
|
|
269
|
+
for i, suggestion in enumerate(brief.suggestions, 1):
|
|
270
|
+
lines.append(f"{i}. {suggestion}")
|
|
271
|
+
|
|
272
|
+
return "\n".join(lines) + "\n"
|
brain/capture.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Pure helpers for the quick-capture inbox (`brain capture`): slug + title build."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
import unicodedata
|
|
6
|
+
from datetime import date
|
|
7
|
+
|
|
8
|
+
# Runs of non-word characters separate slug segments WITHIN a single word.
|
|
9
|
+
# ``\w`` (with re.UNICODE) keeps unicode letters/digits/underscore so accented
|
|
10
|
+
# captures survive instead of being stripped to ASCII.
|
|
11
|
+
_NON_WORD_RUN = re.compile(r"[^\w]+", flags=re.UNICODE)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def slugify_title(text: str, max_words: int) -> str:
|
|
15
|
+
"""Casefold ``text``, keep the first ``max_words`` whitespace words, hyphenate.
|
|
16
|
+
|
|
17
|
+
Words are split on whitespace, so a hyphenated token like ``project-ko``
|
|
18
|
+
counts as one word and survives intact. Within each word, any run of
|
|
19
|
+
non-word characters collapses to a single ``-`` and leading/trailing ``-``
|
|
20
|
+
are trimmed; empty words are dropped. The cleaned words are then joined
|
|
21
|
+
with ``-``.
|
|
22
|
+
|
|
23
|
+
Example::
|
|
24
|
+
|
|
25
|
+
slugify_title("follow up with person-a re project-ko", max_words=6)
|
|
26
|
+
# -> "follow-up-with-person-a-re-project-ko"
|
|
27
|
+
|
|
28
|
+
Raises:
|
|
29
|
+
ValueError: when ``max_words`` is less than 1.
|
|
30
|
+
"""
|
|
31
|
+
if max_words < 1:
|
|
32
|
+
raise ValueError(f"max_words must be >= 1 (got {max_words})")
|
|
33
|
+
folded = unicodedata.normalize("NFKC", text).strip().casefold()
|
|
34
|
+
cleaned: list[str] = []
|
|
35
|
+
for raw_word in folded.split()[:max_words]:
|
|
36
|
+
slug_word = _NON_WORD_RUN.sub("-", raw_word).strip("-")
|
|
37
|
+
if slug_word:
|
|
38
|
+
cleaned.append(slug_word)
|
|
39
|
+
return "-".join(cleaned)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def make_capture_title(content: str, *, today: date, max_words: int) -> str:
|
|
43
|
+
"""Build the deterministic auto-title for a capture.
|
|
44
|
+
|
|
45
|
+
Shape: ``<ISO date>-capture-<slug>`` (e.g. ``2026-06-04-capture-follow-up``).
|
|
46
|
+
``today`` is injected by the caller so this stays pure and frozen-date
|
|
47
|
+
testable — never call ``date.today()`` here.
|
|
48
|
+
"""
|
|
49
|
+
return f"{today.isoformat()}-capture-{slugify_title(content, max_words)}"
|
brain/chat.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Public JSON-mode Ollama chat helper shared across feature modules.
|
|
2
|
+
|
|
3
|
+
Extracted from :meth:`brain.enrichment.OllamaEnricher._chat_with_retry` so
|
|
4
|
+
feature modules (Plan 01 ``brief``, Plan 04 audio, Plan 06 ``ask``) can issue
|
|
5
|
+
JSON-mode ``/api/chat`` round-trips WITHOUT importing ``enrichment`` — which
|
|
6
|
+
would create import cycles (``enrichment`` → feature → ``enrichment``).
|
|
7
|
+
``chat`` is the neutral home: it depends only on :class:`~brain.config.Config`
|
|
8
|
+
and the project error types.
|
|
9
|
+
|
|
10
|
+
Single implementation of the round-trip. :func:`chat_json` is the public
|
|
11
|
+
``Config``-based convenience entry; :func:`chat_json_with_client` is the
|
|
12
|
+
lower-level core (caller supplies the long-lived ``httpx.Client`` and an
|
|
13
|
+
explicit message list). ``OllamaEnricher`` delegates its transport + retry to
|
|
14
|
+
:func:`chat_json_with_client` so the enricher keeps its persistent injected
|
|
15
|
+
client and exact two-message (system + user) wire shape, with zero duplicated
|
|
16
|
+
retry logic.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
|
|
26
|
+
from .config import Config, keep_alive_wire_value
|
|
27
|
+
from .errors import EnrichmentError, OllamaUnavailable
|
|
28
|
+
|
|
29
|
+
_logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
# Default completion-length cap. Matches the historical enricher default so the
|
|
32
|
+
# delegated summary/tag calls keep their exact prior budget.
|
|
33
|
+
DEFAULT_NUM_PREDICT = 256
|
|
34
|
+
|
|
35
|
+
# One chat message: ``{"role": ..., "content": ...}``.
|
|
36
|
+
ChatMessage = dict[str, str]
|
|
37
|
+
|
|
38
|
+
# Stringified boolean tokens local models commonly emit instead of a JSON bool.
|
|
39
|
+
# Shared by :func:`coerce_bool`; kept in lockstep with the (raise-on-unknown)
|
|
40
|
+
# variant in :meth:`brain.enrichment.OllamaEnricher._coerce_contradicts`.
|
|
41
|
+
_TRUE_TOKENS = frozenset({"true", "yes", "1"})
|
|
42
|
+
_FALSE_TOKENS = frozenset({"false", "no", "0"})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def coerce_bool(value: Any, *, default: bool = False) -> bool:
|
|
46
|
+
"""Coerce a model's JSON boolean-ish field into a real ``bool``.
|
|
47
|
+
|
|
48
|
+
Local models frequently emit a *stringified* boolean (``"true"`` /
|
|
49
|
+
``"false"``) or an int (``1`` / ``0``) where a JSON boolean was asked for.
|
|
50
|
+
Passing such a value straight through ``bool(...)`` is a trap: ``bool("false")``
|
|
51
|
+
is ``True`` (any non-empty string is truthy), so a control-flow flag like
|
|
52
|
+
``ask``'s ``sufficient`` verdict silently flips.
|
|
53
|
+
|
|
54
|
+
Normalisation:
|
|
55
|
+
|
|
56
|
+
* a real ``bool`` returns as-is;
|
|
57
|
+
* an ``int`` (``1`` → ``True``, ``0`` → ``False``; any other int → ``default``);
|
|
58
|
+
* a ``str`` matched case-insensitively / trimmed against ``"true"/"yes"/"1"``
|
|
59
|
+
(→ ``True``) and ``"false"/"no"/"0"`` (→ ``False``);
|
|
60
|
+
* anything else (an unrecognised string, a float, a dict, ``None``) falls
|
|
61
|
+
back to ``default``.
|
|
62
|
+
|
|
63
|
+
Unlike :meth:`brain.enrichment.OllamaEnricher._coerce_contradicts` this never
|
|
64
|
+
raises: it is meant for control-flow flags where a safe default beats an
|
|
65
|
+
abort. Callers pick the conservative fallback (``ask`` passes ``default=False``
|
|
66
|
+
so an unparseable sufficiency verdict keeps the retrieval loop going).
|
|
67
|
+
"""
|
|
68
|
+
if isinstance(value, bool):
|
|
69
|
+
return value
|
|
70
|
+
if isinstance(value, int): # bool already handled above; plain 1/0 only.
|
|
71
|
+
if value == 1:
|
|
72
|
+
return True
|
|
73
|
+
if value == 0:
|
|
74
|
+
return False
|
|
75
|
+
return default
|
|
76
|
+
if isinstance(value, str):
|
|
77
|
+
token = value.strip().lower()
|
|
78
|
+
if token in _TRUE_TOKENS:
|
|
79
|
+
return True
|
|
80
|
+
if token in _FALSE_TOKENS:
|
|
81
|
+
return False
|
|
82
|
+
return default
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _build_client(host: str, timeout: float) -> httpx.Client:
|
|
86
|
+
"""Construct the Ollama HTTP client (single seam tests patch).
|
|
87
|
+
|
|
88
|
+
Kept as a tiny factory so unit tests can swap in an ``httpx.Client`` backed
|
|
89
|
+
by a :class:`httpx.MockTransport` without monkey-patching production
|
|
90
|
+
transport internals.
|
|
91
|
+
"""
|
|
92
|
+
return httpx.Client(base_url=host, timeout=httpx.Timeout(timeout))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def chat_json(
|
|
96
|
+
prompt: str,
|
|
97
|
+
*,
|
|
98
|
+
schema: dict[str, Any],
|
|
99
|
+
cfg: Config,
|
|
100
|
+
model: str | None = None,
|
|
101
|
+
num_predict: int | None = None,
|
|
102
|
+
timeout: float | None = None,
|
|
103
|
+
) -> dict[str, Any]:
|
|
104
|
+
"""Issue a JSON-mode ``/api/chat`` call and return the parsed object.
|
|
105
|
+
|
|
106
|
+
Canonical signature shared across Plans 01 / 04 / 06 (locked in the
|
|
107
|
+
roadmap README cross-plan decisions).
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
prompt: The full user-turn instruction. Callers fold any system
|
|
111
|
+
framing directly into this string — ``chat_json`` sends a single
|
|
112
|
+
``user`` message so feature modules own their whole prompt.
|
|
113
|
+
schema: A mapping whose KEYS are the required top-level keys of the
|
|
114
|
+
returned JSON object. Every key must be present in the model's
|
|
115
|
+
response or the call retries (and ultimately raises). Values are
|
|
116
|
+
ignored here (they exist for the caller's own documentation of the
|
|
117
|
+
expected shape).
|
|
118
|
+
cfg: Supplies the Ollama host, default model (``cfg.enrich_model``),
|
|
119
|
+
``keep_alive``, and default timeout (``cfg.enrich_timeout_seconds``).
|
|
120
|
+
model: Per-call model override; defaults to ``cfg.enrich_model``.
|
|
121
|
+
num_predict: Per-call completion-length cap; defaults to
|
|
122
|
+
:data:`DEFAULT_NUM_PREDICT`.
|
|
123
|
+
timeout: Per-call HTTP timeout (seconds); defaults to
|
|
124
|
+
``cfg.enrich_timeout_seconds``.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
The parsed JSON object (a ``dict``) with every key in ``schema`` present.
|
|
128
|
+
|
|
129
|
+
Raises:
|
|
130
|
+
OllamaUnavailable: Transport failure / 5xx (no retry — fail fast).
|
|
131
|
+
EnrichmentError: Two consecutive malformed-JSON / schema-violating
|
|
132
|
+
responses, or a 4xx permanent error.
|
|
133
|
+
"""
|
|
134
|
+
resolved_model = model or cfg.enrich_model
|
|
135
|
+
resolved_timeout = (
|
|
136
|
+
timeout if timeout is not None else cfg.enrich_timeout_seconds
|
|
137
|
+
)
|
|
138
|
+
resolved_num_predict = (
|
|
139
|
+
num_predict if num_predict is not None else DEFAULT_NUM_PREDICT
|
|
140
|
+
)
|
|
141
|
+
required_keys = tuple(schema.keys())
|
|
142
|
+
messages: list[ChatMessage] = [{"role": "user", "content": prompt}]
|
|
143
|
+
with _build_client(cfg.ollama_host, resolved_timeout) as client:
|
|
144
|
+
return chat_json_with_client(
|
|
145
|
+
client,
|
|
146
|
+
model=resolved_model,
|
|
147
|
+
messages=messages,
|
|
148
|
+
required_keys=required_keys,
|
|
149
|
+
keep_alive=cfg.ollama_keep_alive,
|
|
150
|
+
num_predict=resolved_num_predict,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def chat_json_with_client(
|
|
155
|
+
client: httpx.Client,
|
|
156
|
+
*,
|
|
157
|
+
model: str,
|
|
158
|
+
messages: list[ChatMessage],
|
|
159
|
+
required_keys: tuple[str, ...],
|
|
160
|
+
keep_alive: str,
|
|
161
|
+
num_predict: int = DEFAULT_NUM_PREDICT,
|
|
162
|
+
) -> dict[str, Any]:
|
|
163
|
+
"""Issue an ``/api/chat`` JSON-mode call with one retry on parse failure.
|
|
164
|
+
|
|
165
|
+
The shared retry core. The caller supplies the (possibly long-lived)
|
|
166
|
+
``client`` and an explicit ``messages`` list so both the ``Config``-based
|
|
167
|
+
:func:`chat_json` (single user message, per-call client) and
|
|
168
|
+
:class:`brain.enrichment.OllamaEnricher` (system + user messages, persistent
|
|
169
|
+
injected client) route through one implementation.
|
|
170
|
+
|
|
171
|
+
Returns the parsed JSON object on success. Raises
|
|
172
|
+
:class:`OllamaUnavailable` on transient transport errors (no retry — fail
|
|
173
|
+
fast so callers can skip). Raises :class:`EnrichmentError` when two
|
|
174
|
+
consecutive attempts fail JSON parsing / schema validation.
|
|
175
|
+
"""
|
|
176
|
+
last_error: Exception | None = None
|
|
177
|
+
current_num_predict = num_predict
|
|
178
|
+
for attempt in (1, 2):
|
|
179
|
+
try:
|
|
180
|
+
response_text = _chat_once(
|
|
181
|
+
client,
|
|
182
|
+
model=model,
|
|
183
|
+
messages=messages,
|
|
184
|
+
keep_alive=keep_alive,
|
|
185
|
+
num_predict=current_num_predict,
|
|
186
|
+
)
|
|
187
|
+
except OllamaUnavailable:
|
|
188
|
+
# Transient — propagate immediately, no retry.
|
|
189
|
+
raise
|
|
190
|
+
try:
|
|
191
|
+
parsed = json.loads(response_text)
|
|
192
|
+
except json.JSONDecodeError as exc:
|
|
193
|
+
last_error = exc
|
|
194
|
+
# Truncation: the model exhausted ``num_predict`` before closing the
|
|
195
|
+
# JSON. Two signals, both meaning "the response was cut off, not
|
|
196
|
+
# malformed from the start": an unterminated string (cut mid-value),
|
|
197
|
+
# OR a decode failure that lands at/after the end of the received text
|
|
198
|
+
# (cut mid-object — e.g. "Expecting ',' delimiter" / "Expecting
|
|
199
|
+
# property name …" at EOF). Either way the SAME budget would
|
|
200
|
+
# re-truncate deterministically at temperature 0.0, so double
|
|
201
|
+
# ``num_predict`` for the retry to give the next call room to finish.
|
|
202
|
+
# A genuinely malformed response (garbage decoded well before EOF, e.g.
|
|
203
|
+
# "Expecting value" at position 0) is NOT truncation and doesn't bump —
|
|
204
|
+
# the retry there is a deliberate best-effort second try. Schema /
|
|
205
|
+
# non-dict failures don't reach this branch.
|
|
206
|
+
if "Unterminated" in exc.msg or exc.pos >= len(response_text):
|
|
207
|
+
current_num_predict *= 2
|
|
208
|
+
_logger.debug(
|
|
209
|
+
"chat attempt %d: JSON decode failed (%s); response=%r",
|
|
210
|
+
attempt,
|
|
211
|
+
exc,
|
|
212
|
+
response_text[:200],
|
|
213
|
+
)
|
|
214
|
+
continue
|
|
215
|
+
if not isinstance(parsed, dict):
|
|
216
|
+
last_error = EnrichmentError(
|
|
217
|
+
f"chat returned non-object JSON: {parsed!r}"
|
|
218
|
+
)
|
|
219
|
+
continue
|
|
220
|
+
missing = [k for k in required_keys if k not in parsed]
|
|
221
|
+
if missing:
|
|
222
|
+
last_error = EnrichmentError(
|
|
223
|
+
f"chat response missing required keys {missing}: {parsed!r}"
|
|
224
|
+
)
|
|
225
|
+
continue
|
|
226
|
+
return parsed
|
|
227
|
+
# Both attempts failed.
|
|
228
|
+
assert last_error is not None
|
|
229
|
+
raise EnrichmentError(
|
|
230
|
+
f"chat failed after 2 attempts: {last_error}"
|
|
231
|
+
) from last_error
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _chat_once(
|
|
235
|
+
client: httpx.Client,
|
|
236
|
+
*,
|
|
237
|
+
model: str,
|
|
238
|
+
messages: list[ChatMessage],
|
|
239
|
+
keep_alive: str,
|
|
240
|
+
num_predict: int = DEFAULT_NUM_PREDICT,
|
|
241
|
+
) -> str:
|
|
242
|
+
"""Single ``/api/chat`` round-trip returning the inner content string.
|
|
243
|
+
|
|
244
|
+
Returns the ``message.content`` field verbatim so the caller can
|
|
245
|
+
``json.loads`` it. Maps transport-layer failures to
|
|
246
|
+
:class:`OllamaUnavailable` (CLAUDE.md "no bare except" — only
|
|
247
|
+
``httpx.HTTPError`` and ``ValueError`` are caught here, matching the
|
|
248
|
+
embedder's pattern). ``num_predict`` caps the completion length.
|
|
249
|
+
"""
|
|
250
|
+
request_body = {
|
|
251
|
+
"model": model,
|
|
252
|
+
"stream": False,
|
|
253
|
+
"format": "json",
|
|
254
|
+
"keep_alive": keep_alive_wire_value(keep_alive),
|
|
255
|
+
"messages": messages,
|
|
256
|
+
"options": {"temperature": 0.0, "num_predict": num_predict},
|
|
257
|
+
}
|
|
258
|
+
try:
|
|
259
|
+
response = client.post("/api/chat", json=request_body)
|
|
260
|
+
response.raise_for_status()
|
|
261
|
+
payload = response.json()
|
|
262
|
+
except httpx.HTTPStatusError as exc:
|
|
263
|
+
status = exc.response.status_code if exc.response is not None else 0
|
|
264
|
+
body_preview = (
|
|
265
|
+
exc.response.text[:200] if exc.response is not None else "<no body>"
|
|
266
|
+
)
|
|
267
|
+
if status >= 500:
|
|
268
|
+
raise OllamaUnavailable(
|
|
269
|
+
f"Ollama returned HTTP {status}: {body_preview}"
|
|
270
|
+
) from exc
|
|
271
|
+
# 4xx — permanent (bad model name, malformed request).
|
|
272
|
+
raise EnrichmentError(
|
|
273
|
+
f"Ollama returned HTTP {status}: {body_preview}"
|
|
274
|
+
) from exc
|
|
275
|
+
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout) as exc:
|
|
276
|
+
raise OllamaUnavailable(f"Ollama unreachable: {exc}") from exc
|
|
277
|
+
except httpx.HTTPError as exc:
|
|
278
|
+
raise OllamaUnavailable(f"Ollama transport error: {exc}") from exc
|
|
279
|
+
except ValueError as exc:
|
|
280
|
+
# json.JSONDecodeError is a ValueError — a 200 OK with non-JSON body
|
|
281
|
+
# leaks as a raw decode error otherwise.
|
|
282
|
+
raise EnrichmentError(
|
|
283
|
+
f"Ollama returned non-JSON envelope: {exc}"
|
|
284
|
+
) from exc
|
|
285
|
+
message = payload.get("message") if isinstance(payload, dict) else None
|
|
286
|
+
if not isinstance(message, dict):
|
|
287
|
+
raise EnrichmentError(f"Ollama response missing 'message': {payload!r}")
|
|
288
|
+
content = message.get("content")
|
|
289
|
+
if not isinstance(content, str):
|
|
290
|
+
raise EnrichmentError(
|
|
291
|
+
f"Ollama message.content is not a string: {message!r}"
|
|
292
|
+
)
|
|
293
|
+
return content
|