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/ask.py
ADDED
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
"""Agentic multi-hop cited answer synthesis (`brain ask`, Plan 06).
|
|
2
|
+
|
|
3
|
+
Orchestrates a plan -> retrieve -> reflect -> synthesize loop over the existing
|
|
4
|
+
hybrid-search (and optional graph) retrieval primitives, then composes a single
|
|
5
|
+
cited answer. This module owns ONLY the loop + the LLM prompt strings; it has no
|
|
6
|
+
CLI or MCP knowledge. The three LLM steps go through an injected
|
|
7
|
+
:data:`ChatJson` callable (the public :func:`brain.chat.chat_json`) so the whole
|
|
8
|
+
loop is unit-testable with a fake chat and never needs a live Ollama.
|
|
9
|
+
|
|
10
|
+
Privacy: the prompts receive document ``title + snippet`` only (snippet capped at
|
|
11
|
+
:data:`_SYNTH_SNIPPET_CHARS`), never full bodies -- mirroring the enricher's
|
|
12
|
+
head-only truncation policy so no transcript/email body is sent to the LLM.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import re
|
|
18
|
+
import uuid
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
21
|
+
|
|
22
|
+
import psycopg
|
|
23
|
+
|
|
24
|
+
from .chat import coerce_bool
|
|
25
|
+
from .search import SearchResult, hybrid_search
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from .config import Config
|
|
29
|
+
from .graph_rag.backends.base import GraphBackend
|
|
30
|
+
from .graph_rag.schema import GraphContext
|
|
31
|
+
from .ingest import Embedder
|
|
32
|
+
|
|
33
|
+
_logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
# Retrieval mode vocabulary accepted by `brain ask`. ``hybrid`` (the default) is
|
|
36
|
+
# vector/FTS only; the other three add a graph leg via ``graph_rag_search``.
|
|
37
|
+
HYBRID_MODE = "hybrid"
|
|
38
|
+
ASK_MODES: frozenset[str] = frozenset({"hybrid", "auto", "fuse", "local"})
|
|
39
|
+
|
|
40
|
+
# Fallback default when a caller omits ``max_iterations``; the CLI/MCP layers
|
|
41
|
+
# pass ``cfg.ask_max_iterations`` explicitly (this mirrors the plan signature
|
|
42
|
+
# default of 3 without importing Config at module load time).
|
|
43
|
+
_DEFAULT_MAX_ITERATIONS = 3
|
|
44
|
+
|
|
45
|
+
# Per-LLM-step completion-length budgets (tokens). Plan/reflect are tiny JSON
|
|
46
|
+
# objects; synthesize needs room for a few sentences of cited prose.
|
|
47
|
+
_PLAN_NUM_PREDICT = 128
|
|
48
|
+
_REFLECT_NUM_PREDICT = 128
|
|
49
|
+
_SYNTH_NUM_PREDICT = 512
|
|
50
|
+
|
|
51
|
+
# Sub-query count bounds returned by the plan step.
|
|
52
|
+
_MAX_SUB_QUERIES = 3
|
|
53
|
+
# Follow-up sub-query count bound returned by the reflect step.
|
|
54
|
+
_MAX_FOLLOW_UPS = 2
|
|
55
|
+
|
|
56
|
+
# Snippet truncation for the reflect step (titles + short snippets only).
|
|
57
|
+
_REFLECT_SNIPPET_CHARS = 120
|
|
58
|
+
# Snippet truncation for the synthesize step (privacy / context-window safety).
|
|
59
|
+
_SYNTH_SNIPPET_CHARS = 400
|
|
60
|
+
|
|
61
|
+
# Matches a ``[N]`` citation marker (1+ digits) in a synthesized answer.
|
|
62
|
+
_CITATION_RE = re.compile(r"\[(\d+)\]")
|
|
63
|
+
|
|
64
|
+
# Matches a ``[N]`` marker plus any leading whitespace, for sanitizing dangling
|
|
65
|
+
# (out-of-range) markers out of the final answer text — the marker AND the space
|
|
66
|
+
# that precedes it are removed so " foo [99] bar" collapses cleanly to "foo bar".
|
|
67
|
+
_MARKER_WITH_LEAD_RE = re.compile(r"\s*\[(\d+)\]")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ChatJson(Protocol):
|
|
71
|
+
"""Structural type of :func:`brain.chat.chat_json` (the injected LLM seam).
|
|
72
|
+
|
|
73
|
+
Production passes the real ``chat_json``; tests pass a closure with the same
|
|
74
|
+
signature. Keeping it a ``Protocol`` (not a hard import of the function) lets
|
|
75
|
+
the loop stay decoupled from the transport and trivially mockable.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __call__(
|
|
79
|
+
self,
|
|
80
|
+
prompt: str,
|
|
81
|
+
*,
|
|
82
|
+
schema: dict[str, Any],
|
|
83
|
+
cfg: Config,
|
|
84
|
+
model: str | None = ...,
|
|
85
|
+
num_predict: int | None = ...,
|
|
86
|
+
timeout: float | None = ...,
|
|
87
|
+
) -> dict[str, Any]: ...
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class Citation:
|
|
92
|
+
"""One cited source backing an answer, mapped from a ``[N]`` marker.
|
|
93
|
+
|
|
94
|
+
``ref`` is the 1-indexed number that appears as ``[ref]`` in the answer text
|
|
95
|
+
(NOT renumbered, so the marker always resolves to a real source).
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
ref: int
|
|
99
|
+
document_id: str
|
|
100
|
+
title: str
|
|
101
|
+
source_kind: str | None
|
|
102
|
+
snippet: str
|
|
103
|
+
|
|
104
|
+
def to_dict(self) -> dict[str, Any]:
|
|
105
|
+
"""JSON projection (CLI ``--json`` / MCP wire shape)."""
|
|
106
|
+
return {
|
|
107
|
+
"ref": self.ref,
|
|
108
|
+
"document_id": self.document_id,
|
|
109
|
+
"title": self.title,
|
|
110
|
+
"source_kind": self.source_kind,
|
|
111
|
+
"snippet": self.snippet,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True)
|
|
116
|
+
class AskResult:
|
|
117
|
+
"""The synthesized answer plus its provenance.
|
|
118
|
+
|
|
119
|
+
``citations`` are ordered by first appearance of their ``[ref]`` marker in
|
|
120
|
+
``answer`` and contain ONLY sources the answer actually cites.
|
|
121
|
+
``fallback_used`` is ``True`` when the agentic planning loop was skipped or
|
|
122
|
+
degraded: the ``no_loop`` fast path, or a plan step that yielded no usable
|
|
123
|
+
sub-queries (so the raw question was used as the single sub-query).
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
answer: str
|
|
127
|
+
citations: list[Citation]
|
|
128
|
+
iterations_used: int
|
|
129
|
+
sub_queries: list[str]
|
|
130
|
+
fallback_used: bool
|
|
131
|
+
session_id: str
|
|
132
|
+
|
|
133
|
+
def to_dict(self) -> dict[str, Any]:
|
|
134
|
+
"""JSON projection shared by the CLI ``--json`` output and the MCP tool."""
|
|
135
|
+
return {
|
|
136
|
+
"answer": self.answer,
|
|
137
|
+
"citations": [c.to_dict() for c in self.citations],
|
|
138
|
+
"iterations_used": self.iterations_used,
|
|
139
|
+
"sub_queries": list(self.sub_queries),
|
|
140
|
+
"fallback_used": self.fallback_used,
|
|
141
|
+
"session_id": self.session_id,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True)
|
|
146
|
+
class _ReflectVerdict:
|
|
147
|
+
"""Parsed result of the reflect step."""
|
|
148
|
+
|
|
149
|
+
sufficient: bool
|
|
150
|
+
follow_up_queries: list[str]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
# LLM step prompts + parsers (pure-logic; mockable via the injected ``chat``)
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _plan_prompt(question: str) -> str:
|
|
159
|
+
"""Build the plan-step prompt: decompose ``question`` into sub-queries."""
|
|
160
|
+
return (
|
|
161
|
+
"You are a retrieval planner for a personal knowledge base. Decompose "
|
|
162
|
+
"the user's question into 1 to 3 focused search sub-queries that, "
|
|
163
|
+
"together, would retrieve the documents needed to answer it. Each "
|
|
164
|
+
"sub-query should be a short keyword phrase, not a full sentence. "
|
|
165
|
+
"Respond ONLY with a JSON object of the form "
|
|
166
|
+
'{"sub_queries": ["...", "..."]}.\n\n'
|
|
167
|
+
f"Question: {question}"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _call_plan_step(chat: ChatJson, cfg: Config, question: str) -> list[str]:
|
|
172
|
+
"""Run the plan step and return 1-3 sub-queries (clamped + de-duplicated).
|
|
173
|
+
|
|
174
|
+
Returns an empty list only if the model produced no usable string
|
|
175
|
+
sub-queries; the caller then falls back to ``[question]``.
|
|
176
|
+
"""
|
|
177
|
+
body = chat(
|
|
178
|
+
_plan_prompt(question),
|
|
179
|
+
schema={"sub_queries": "list"},
|
|
180
|
+
cfg=cfg,
|
|
181
|
+
model=cfg.ask_model,
|
|
182
|
+
num_predict=_PLAN_NUM_PREDICT,
|
|
183
|
+
timeout=cfg.ask_timeout_seconds,
|
|
184
|
+
)
|
|
185
|
+
return _clean_query_list(body.get("sub_queries"), limit=_MAX_SUB_QUERIES)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _reflect_prompt(question: str, docs: list[SearchResult]) -> str:
|
|
189
|
+
"""Build the reflect-step prompt: is coverage sufficient for ``question``?"""
|
|
190
|
+
lines = [
|
|
191
|
+
"You are assessing whether the retrieved documents are sufficient to "
|
|
192
|
+
"answer the user's question. If they are sufficient, respond with "
|
|
193
|
+
'{"sufficient": true, "follow_up_queries": []}. If not, respond with '
|
|
194
|
+
'{"sufficient": false, "follow_up_queries": ["...", "..."]} providing '
|
|
195
|
+
"up to 2 NEW short search sub-queries that would fill the gaps. "
|
|
196
|
+
"Respond ONLY with that JSON object.\n",
|
|
197
|
+
f"Question: {question}\n",
|
|
198
|
+
"Retrieved documents:",
|
|
199
|
+
]
|
|
200
|
+
if docs:
|
|
201
|
+
for i, doc in enumerate(docs, start=1):
|
|
202
|
+
snippet = _truncate(doc.snippet, _REFLECT_SNIPPET_CHARS)
|
|
203
|
+
lines.append(f"[{i}] {doc.title} — {snippet}")
|
|
204
|
+
else:
|
|
205
|
+
lines.append("(none retrieved)")
|
|
206
|
+
return "\n".join(lines)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _call_reflect_step(
|
|
210
|
+
chat: ChatJson, cfg: Config, question: str, docs: list[SearchResult]
|
|
211
|
+
) -> _ReflectVerdict:
|
|
212
|
+
"""Run the reflect step and return the sufficiency verdict + follow-ups."""
|
|
213
|
+
body = chat(
|
|
214
|
+
_reflect_prompt(question, docs),
|
|
215
|
+
schema={"sufficient": "bool", "follow_up_queries": "list"},
|
|
216
|
+
cfg=cfg,
|
|
217
|
+
model=cfg.ask_model,
|
|
218
|
+
num_predict=_REFLECT_NUM_PREDICT,
|
|
219
|
+
timeout=cfg.ask_timeout_seconds,
|
|
220
|
+
)
|
|
221
|
+
# Coerce defensively: a local model that returns ``"sufficient": "false"``
|
|
222
|
+
# would be treated as True by a bare ``bool(...)`` (non-empty string is
|
|
223
|
+
# truthy), stopping the loop early. ``coerce_bool`` maps stringified/int
|
|
224
|
+
# booleans correctly and defaults unparseable verdicts to False (insufficient)
|
|
225
|
+
# so the loop keeps retrieving rather than truncating on garbage.
|
|
226
|
+
sufficient = coerce_bool(body.get("sufficient"))
|
|
227
|
+
follow_ups = _clean_query_list(
|
|
228
|
+
body.get("follow_up_queries"), limit=_MAX_FOLLOW_UPS
|
|
229
|
+
)
|
|
230
|
+
return _ReflectVerdict(sufficient=sufficient, follow_up_queries=follow_ups)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _synthesize_prompt(
|
|
234
|
+
question: str, docs: list[SearchResult], graph_summary: str
|
|
235
|
+
) -> str:
|
|
236
|
+
"""Build the synthesize-step prompt over numbered title+snippet docs."""
|
|
237
|
+
lines = [
|
|
238
|
+
"You are answering the user's question using ONLY the numbered source "
|
|
239
|
+
"documents below. Write a direct, concise answer. After each claim, cite "
|
|
240
|
+
"the source it came from using its bracketed number exactly as shown "
|
|
241
|
+
"below — for example [1] or [2] (use the real digits, never a literal "
|
|
242
|
+
"letter). If the documents do not contain enough information to answer, "
|
|
243
|
+
"say so plainly and do not invent facts. Respond ONLY with a JSON object "
|
|
244
|
+
'of the form {"answer": "your answer with [1]-style citations"}.\n',
|
|
245
|
+
f"Question: {question}\n",
|
|
246
|
+
]
|
|
247
|
+
if graph_summary:
|
|
248
|
+
lines.append(f"Graph context: {graph_summary}\n")
|
|
249
|
+
lines.append("Sources:")
|
|
250
|
+
if docs:
|
|
251
|
+
for i, doc in enumerate(docs, start=1):
|
|
252
|
+
snippet = _truncate(doc.snippet, _SYNTH_SNIPPET_CHARS)
|
|
253
|
+
lines.append(f"[{i}] {doc.title} — {snippet}")
|
|
254
|
+
else:
|
|
255
|
+
lines.append("(no documents found)")
|
|
256
|
+
return "\n".join(lines)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _call_synthesize_step(
|
|
260
|
+
chat: ChatJson,
|
|
261
|
+
cfg: Config,
|
|
262
|
+
question: str,
|
|
263
|
+
docs: list[SearchResult],
|
|
264
|
+
graph_summary: str,
|
|
265
|
+
) -> str:
|
|
266
|
+
"""Run the synthesize step and return the raw answer string."""
|
|
267
|
+
body = chat(
|
|
268
|
+
_synthesize_prompt(question, docs, graph_summary),
|
|
269
|
+
schema={"answer": "str"},
|
|
270
|
+
cfg=cfg,
|
|
271
|
+
model=cfg.ask_model,
|
|
272
|
+
num_predict=_SYNTH_NUM_PREDICT,
|
|
273
|
+
timeout=cfg.ask_timeout_seconds,
|
|
274
|
+
)
|
|
275
|
+
answer = body.get("answer")
|
|
276
|
+
return answer if isinstance(answer, str) else ""
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# ---------------------------------------------------------------------------
|
|
280
|
+
# Citation tracking
|
|
281
|
+
# ---------------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _sanitize_answer(answer: str, doc_count: int) -> str:
|
|
285
|
+
"""Strip out-of-range ``[N]`` markers from the answer text.
|
|
286
|
+
|
|
287
|
+
Citation integrity: a marker whose number has no backing source (``N`` >
|
|
288
|
+
``doc_count`` or ``N`` < 1) is a dangling citation. Removing it — along with
|
|
289
|
+
its leading whitespace — keeps the displayed/returned answer free of
|
|
290
|
+
references that resolve to nothing, while valid in-range markers are kept
|
|
291
|
+
verbatim so :func:`_build_citations` still maps them.
|
|
292
|
+
"""
|
|
293
|
+
|
|
294
|
+
def _replace(match: re.Match[str]) -> str:
|
|
295
|
+
ref = int(match.group(1))
|
|
296
|
+
if 1 <= ref <= doc_count:
|
|
297
|
+
return match.group(0) # keep the valid marker (and its lead space)
|
|
298
|
+
return ""
|
|
299
|
+
|
|
300
|
+
return _MARKER_WITH_LEAD_RE.sub(_replace, answer).strip()
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _build_citations(answer: str, docs: list[SearchResult]) -> list[Citation]:
|
|
304
|
+
"""Map ``[N]`` markers in ``answer`` to the numbered ``docs`` list.
|
|
305
|
+
|
|
306
|
+
Keeps only in-range references (1..len(docs)), de-duplicated, ordered by
|
|
307
|
+
first appearance in the answer text. ``ref`` preserves the original marker
|
|
308
|
+
number so ``[ref]`` in the prose always resolves to a real source — and a
|
|
309
|
+
hallucinated marker (out of range) is silently dropped, never emitted as a
|
|
310
|
+
citation (citation integrity).
|
|
311
|
+
"""
|
|
312
|
+
citations: list[Citation] = []
|
|
313
|
+
seen: set[int] = set()
|
|
314
|
+
for match in _CITATION_RE.finditer(answer):
|
|
315
|
+
ref = int(match.group(1))
|
|
316
|
+
if ref in seen:
|
|
317
|
+
continue
|
|
318
|
+
if ref < 1 or ref > len(docs):
|
|
319
|
+
continue
|
|
320
|
+
seen.add(ref)
|
|
321
|
+
doc = docs[ref - 1]
|
|
322
|
+
citations.append(
|
|
323
|
+
Citation(
|
|
324
|
+
ref=ref,
|
|
325
|
+
document_id=doc.document_id,
|
|
326
|
+
title=doc.title,
|
|
327
|
+
source_kind=doc.source_kind,
|
|
328
|
+
snippet=_truncate(doc.snippet, _SYNTH_SNIPPET_CHARS),
|
|
329
|
+
)
|
|
330
|
+
)
|
|
331
|
+
return citations
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# ---------------------------------------------------------------------------
|
|
335
|
+
# Retrieval
|
|
336
|
+
# ---------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _graph_summary(ctx: GraphContext) -> str:
|
|
340
|
+
"""Build a one-line graph-context label list from a ``GraphContext``.
|
|
341
|
+
|
|
342
|
+
For ``themes`` mode uses each :class:`ThemeGroup`'s ``summary`` (when the
|
|
343
|
+
opt-in synthesis ran) else its top entity names; for ``global`` mode each
|
|
344
|
+
:class:`CommunityGroup`'s ``summary`` else its ``community_key``; for
|
|
345
|
+
``local`` mode the reached entity names. Empty string when the graph leg
|
|
346
|
+
produced nothing — keeps the synthesize prompt clean.
|
|
347
|
+
"""
|
|
348
|
+
labels: list[str] = []
|
|
349
|
+
for theme in ctx.themes:
|
|
350
|
+
if theme.summary:
|
|
351
|
+
labels.append(theme.summary)
|
|
352
|
+
else:
|
|
353
|
+
labels.extend(e.name for e in theme.entities[:3] if e.name)
|
|
354
|
+
for community in ctx.communities:
|
|
355
|
+
labels.append(community.summary or community.community_key)
|
|
356
|
+
for entity in ctx.entities:
|
|
357
|
+
if entity.name:
|
|
358
|
+
labels.append(entity.name)
|
|
359
|
+
# De-duplicate preserving order, cap to keep the prompt bounded.
|
|
360
|
+
deduped: list[str] = []
|
|
361
|
+
for label in labels:
|
|
362
|
+
if label and label not in deduped:
|
|
363
|
+
deduped.append(label)
|
|
364
|
+
return ", ".join(deduped[:10])
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _retrieve_hybrid(
|
|
368
|
+
conn: psycopg.Connection[Any],
|
|
369
|
+
cfg: Config,
|
|
370
|
+
*,
|
|
371
|
+
embedder: Embedder,
|
|
372
|
+
query: str,
|
|
373
|
+
limit: int,
|
|
374
|
+
) -> list[SearchResult]:
|
|
375
|
+
"""Single hybrid-search pass for one sub-query (config-driven knobs)."""
|
|
376
|
+
return hybrid_search(
|
|
377
|
+
conn,
|
|
378
|
+
embedder=embedder,
|
|
379
|
+
query=query,
|
|
380
|
+
limit=limit,
|
|
381
|
+
vector_sim_floor=cfg.vector_sim_floor,
|
|
382
|
+
recency_halflife_days=cfg.recency_halflife_days,
|
|
383
|
+
snippet_context_tokens=cfg.snippet_context_tokens,
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _retrieve(
|
|
388
|
+
conn: psycopg.Connection[Any],
|
|
389
|
+
cfg: Config,
|
|
390
|
+
*,
|
|
391
|
+
embedder: Embedder,
|
|
392
|
+
query: str,
|
|
393
|
+
limit: int,
|
|
394
|
+
mode: str,
|
|
395
|
+
backend: GraphBackend | None,
|
|
396
|
+
) -> tuple[list[SearchResult], str]:
|
|
397
|
+
"""Retrieve documents for one sub-query; returns (results, graph_summary).
|
|
398
|
+
|
|
399
|
+
The hybrid leg always runs. For ``mode != hybrid`` the graph leg runs too
|
|
400
|
+
(requires ``backend``) and its docs are appended after the hybrid docs; the
|
|
401
|
+
returned ``graph_summary`` carries the theme/community/entity labels for the
|
|
402
|
+
synthesize prompt. A graph leg that yields nothing degrades silently to the
|
|
403
|
+
hybrid docs (never-raise on empty, matching the graph layer's contract).
|
|
404
|
+
"""
|
|
405
|
+
# Validate the graph precondition BEFORE the (DB-touching) hybrid leg so a
|
|
406
|
+
# missing backend fails fast as a clean caller error.
|
|
407
|
+
if mode != HYBRID_MODE and backend is None:
|
|
408
|
+
raise ValueError(
|
|
409
|
+
f"mode={mode!r} requires a graph backend; pass backend=AgeBackend()"
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
results = _retrieve_hybrid(
|
|
413
|
+
conn, cfg, embedder=embedder, query=query, limit=limit
|
|
414
|
+
)
|
|
415
|
+
if mode == HYBRID_MODE:
|
|
416
|
+
return results, ""
|
|
417
|
+
|
|
418
|
+
assert backend is not None # guarded above
|
|
419
|
+
from .graph_rag import graph_rag_search
|
|
420
|
+
|
|
421
|
+
ctx = graph_rag_search(
|
|
422
|
+
conn,
|
|
423
|
+
cfg,
|
|
424
|
+
query,
|
|
425
|
+
backend=backend,
|
|
426
|
+
mode=mode,
|
|
427
|
+
limit=limit,
|
|
428
|
+
embedder=embedder,
|
|
429
|
+
)
|
|
430
|
+
combined = list(results)
|
|
431
|
+
seen_ids = {r.document_id for r in combined}
|
|
432
|
+
for doc in ctx.docs:
|
|
433
|
+
if doc.document_id not in seen_ids:
|
|
434
|
+
seen_ids.add(doc.document_id)
|
|
435
|
+
combined.append(doc)
|
|
436
|
+
return combined, _graph_summary(ctx)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _clean_query_list(raw: Any, *, limit: int) -> list[str]:
|
|
440
|
+
"""Coerce a model's list field into 0..limit non-empty unique strings."""
|
|
441
|
+
if not isinstance(raw, list):
|
|
442
|
+
return []
|
|
443
|
+
cleaned: list[str] = []
|
|
444
|
+
for item in raw:
|
|
445
|
+
if not isinstance(item, str):
|
|
446
|
+
continue
|
|
447
|
+
stripped = item.strip()
|
|
448
|
+
if stripped and stripped not in cleaned:
|
|
449
|
+
cleaned.append(stripped)
|
|
450
|
+
if len(cleaned) >= limit:
|
|
451
|
+
break
|
|
452
|
+
return cleaned
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _truncate(text: str, limit: int) -> str:
|
|
456
|
+
"""Trim ``text`` to ``limit`` chars (collapsing trailing whitespace)."""
|
|
457
|
+
if len(text) <= limit:
|
|
458
|
+
return text
|
|
459
|
+
return text[:limit].rstrip()
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
# ---------------------------------------------------------------------------
|
|
463
|
+
# Public entry points
|
|
464
|
+
# ---------------------------------------------------------------------------
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def ask_no_loop(
|
|
468
|
+
conn: psycopg.Connection[Any],
|
|
469
|
+
cfg: Config,
|
|
470
|
+
*,
|
|
471
|
+
embedder: Embedder,
|
|
472
|
+
chat: ChatJson,
|
|
473
|
+
question: str,
|
|
474
|
+
mode: str = HYBRID_MODE,
|
|
475
|
+
limit: int = 5,
|
|
476
|
+
backend: GraphBackend | None = None,
|
|
477
|
+
) -> AskResult:
|
|
478
|
+
"""Single retrieve + synthesize pass (the ``--no-loop`` fast path).
|
|
479
|
+
|
|
480
|
+
Skips the plan/reflect LLM steps: retrieves once for ``question`` and
|
|
481
|
+
synthesizes a cited answer. ``fallback_used`` is always ``True`` here (no
|
|
482
|
+
agentic planning was performed).
|
|
483
|
+
"""
|
|
484
|
+
_validate_mode(mode)
|
|
485
|
+
session_id = uuid.uuid4().hex
|
|
486
|
+
docs, graph_summary = _retrieve(
|
|
487
|
+
conn,
|
|
488
|
+
cfg,
|
|
489
|
+
embedder=embedder,
|
|
490
|
+
query=question,
|
|
491
|
+
limit=limit,
|
|
492
|
+
mode=mode,
|
|
493
|
+
backend=backend,
|
|
494
|
+
)
|
|
495
|
+
raw_answer = _call_synthesize_step(chat, cfg, question, docs, graph_summary)
|
|
496
|
+
answer = _sanitize_answer(raw_answer, len(docs))
|
|
497
|
+
citations = _build_citations(answer, docs)
|
|
498
|
+
return AskResult(
|
|
499
|
+
answer=answer,
|
|
500
|
+
citations=citations,
|
|
501
|
+
iterations_used=1,
|
|
502
|
+
sub_queries=[question],
|
|
503
|
+
fallback_used=True,
|
|
504
|
+
session_id=session_id,
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def ask(
|
|
509
|
+
conn: psycopg.Connection[Any],
|
|
510
|
+
cfg: Config,
|
|
511
|
+
*,
|
|
512
|
+
embedder: Embedder,
|
|
513
|
+
chat: ChatJson,
|
|
514
|
+
question: str,
|
|
515
|
+
mode: str = HYBRID_MODE,
|
|
516
|
+
no_loop: bool = False,
|
|
517
|
+
limit: int = 5,
|
|
518
|
+
max_iterations: int = _DEFAULT_MAX_ITERATIONS,
|
|
519
|
+
backend: GraphBackend | None = None,
|
|
520
|
+
) -> AskResult:
|
|
521
|
+
"""Agentic plan -> retrieve -> reflect -> synthesize loop over the corpus.
|
|
522
|
+
|
|
523
|
+
Plans 1-3 sub-queries, retrieves (dedup by document id across iterations),
|
|
524
|
+
reflects on coverage, optionally issues follow-up sub-queries, then
|
|
525
|
+
synthesizes a single cited answer. ``no_loop=True`` delegates to
|
|
526
|
+
:func:`ask_no_loop`. ``max_iterations`` hard-caps the loop.
|
|
527
|
+
|
|
528
|
+
The loop stops early when the reflect step reports coverage is sufficient,
|
|
529
|
+
when an iteration surfaces no new documents, or when no follow-up sub-queries
|
|
530
|
+
are produced. ``OllamaUnavailable`` / ``EnrichmentError`` from any LLM step
|
|
531
|
+
propagates to the caller (no silent degradation).
|
|
532
|
+
"""
|
|
533
|
+
_validate_mode(mode)
|
|
534
|
+
if max_iterations < 1:
|
|
535
|
+
raise ValueError(
|
|
536
|
+
f"max_iterations must be >= 1 (got {max_iterations})"
|
|
537
|
+
)
|
|
538
|
+
if no_loop:
|
|
539
|
+
return ask_no_loop(
|
|
540
|
+
conn,
|
|
541
|
+
cfg,
|
|
542
|
+
embedder=embedder,
|
|
543
|
+
chat=chat,
|
|
544
|
+
question=question,
|
|
545
|
+
mode=mode,
|
|
546
|
+
limit=limit,
|
|
547
|
+
backend=backend,
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
session_id = uuid.uuid4().hex
|
|
551
|
+
|
|
552
|
+
# Step 1 — Plan. Fall back to the raw question when the planner yields none.
|
|
553
|
+
sub_queries = _call_plan_step(chat, cfg, question)
|
|
554
|
+
fallback_used = False
|
|
555
|
+
if not sub_queries:
|
|
556
|
+
sub_queries = [question]
|
|
557
|
+
fallback_used = True
|
|
558
|
+
|
|
559
|
+
all_sub_queries: list[str] = list(sub_queries)
|
|
560
|
+
all_docs: dict[str, SearchResult] = {}
|
|
561
|
+
graph_summaries: list[str] = []
|
|
562
|
+
iterations_used = 0
|
|
563
|
+
|
|
564
|
+
for iteration in range(1, max_iterations + 1):
|
|
565
|
+
iterations_used = iteration
|
|
566
|
+
# Step 2 — Retrieve (dedup across iterations).
|
|
567
|
+
new_doc_found = False
|
|
568
|
+
for sub_query in sub_queries:
|
|
569
|
+
results, graph_summary = _retrieve(
|
|
570
|
+
conn,
|
|
571
|
+
cfg,
|
|
572
|
+
embedder=embedder,
|
|
573
|
+
query=sub_query,
|
|
574
|
+
limit=limit,
|
|
575
|
+
mode=mode,
|
|
576
|
+
backend=backend,
|
|
577
|
+
)
|
|
578
|
+
if graph_summary:
|
|
579
|
+
graph_summaries.append(graph_summary)
|
|
580
|
+
for result in results:
|
|
581
|
+
if result.document_id not in all_docs:
|
|
582
|
+
all_docs[result.document_id] = result
|
|
583
|
+
new_doc_found = True
|
|
584
|
+
|
|
585
|
+
# Step 3 — Reflect (skip on the last iteration or when nothing new).
|
|
586
|
+
if iteration >= max_iterations or not new_doc_found:
|
|
587
|
+
break
|
|
588
|
+
verdict = _call_reflect_step(
|
|
589
|
+
chat, cfg, question, list(all_docs.values())
|
|
590
|
+
)
|
|
591
|
+
if verdict.sufficient or not verdict.follow_up_queries:
|
|
592
|
+
break
|
|
593
|
+
sub_queries = verdict.follow_up_queries
|
|
594
|
+
for follow_up in sub_queries:
|
|
595
|
+
if follow_up not in all_sub_queries:
|
|
596
|
+
all_sub_queries.append(follow_up)
|
|
597
|
+
|
|
598
|
+
# Step 4 — Synthesize over the accumulated, deduplicated documents.
|
|
599
|
+
docs = list(all_docs.values())
|
|
600
|
+
combined_graph_summary = _join_graph_summaries(graph_summaries)
|
|
601
|
+
raw_answer = _call_synthesize_step(
|
|
602
|
+
chat, cfg, question, docs, combined_graph_summary
|
|
603
|
+
)
|
|
604
|
+
answer = _sanitize_answer(raw_answer, len(docs))
|
|
605
|
+
citations = _build_citations(answer, docs)
|
|
606
|
+
return AskResult(
|
|
607
|
+
answer=answer,
|
|
608
|
+
citations=citations,
|
|
609
|
+
iterations_used=iterations_used,
|
|
610
|
+
sub_queries=all_sub_queries,
|
|
611
|
+
fallback_used=fallback_used,
|
|
612
|
+
session_id=session_id,
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _join_graph_summaries(summaries: list[str]) -> str:
|
|
617
|
+
"""De-duplicate label tokens across iteration graph summaries into one line."""
|
|
618
|
+
labels: list[str] = []
|
|
619
|
+
for summary in summaries:
|
|
620
|
+
for label in summary.split(", "):
|
|
621
|
+
label = label.strip()
|
|
622
|
+
if label and label not in labels:
|
|
623
|
+
labels.append(label)
|
|
624
|
+
return ", ".join(labels[:10])
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _validate_mode(mode: str) -> None:
|
|
628
|
+
"""Raise ``ValueError`` for an unrecognized ``--mode`` value."""
|
|
629
|
+
if mode not in ASK_MODES:
|
|
630
|
+
valid = ", ".join(sorted(ASK_MODES))
|
|
631
|
+
raise ValueError(f"unknown mode {mode!r}; valid modes: {valid}")
|