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
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""Concept-extractor eval gate metric (wave G2-j, GraphRAG; spec §17b decision 2).
|
|
2
|
+
|
|
3
|
+
The gate that informs the **later** decision to flip ``BRAIN_GRAPH_CONCEPTS``
|
|
4
|
+
default-ON. **G2 ships concepts default-OFF regardless of this gate** — this
|
|
5
|
+
module is a *measurement / decision* tool, not a G2 blocker (spec §17b
|
|
6
|
+
decision 2: "Passing this gate is necessary but not sufficient to flip the
|
|
7
|
+
default; ``BRAIN_GRAPH_CONCEPTS`` stays default-OFF for the G2 ship").
|
|
8
|
+
|
|
9
|
+
**Metric (normative, spec §17b decision 2).** Document-level, **type-aware**
|
|
10
|
+
concept-set **micro-F1** over unique ``(entity_type, canonical_key)`` pairs after
|
|
11
|
+
canonicalization, **people excluded**. Aggregation is *micro* — true/false
|
|
12
|
+
positives and false negatives are summed across every document, then a single
|
|
13
|
+
precision/recall/F1 is computed from those totals (so a document with many
|
|
14
|
+
concepts weighs more than one with few, which is the right unit for an
|
|
15
|
+
extraction gate). Type-awareness means ``("org", "acme")`` and
|
|
16
|
+
``("project", "acme")`` are **distinct** pairs. People are excluded because they
|
|
17
|
+
are derived for free from the participants pipeline, not the LLM extractor.
|
|
18
|
+
|
|
19
|
+
**Pass threshold (normative).** ``micro_f1 >= 0.80`` AND ``precision >= 0.85``
|
|
20
|
+
AND ``recall >= 0.70`` AND ``invalid_json_or_schema_rate == 0`` — see
|
|
21
|
+
:attr:`ConceptF1Report.passes`.
|
|
22
|
+
|
|
23
|
+
``invalid_json_or_schema_rate`` is the fraction of documents whose extraction
|
|
24
|
+
produced malformed / schema-invalid model output (the live gate detects this via
|
|
25
|
+
the :class:`~brain.graph_rag.extract.OllamaExtractor`'s documented
|
|
26
|
+
"skipping chunk" WARN; an invalid document contributes **no** predicted pairs,
|
|
27
|
+
so all its gold pairs become false negatives).
|
|
28
|
+
|
|
29
|
+
This module is **pure** — no DB, no Ollama, no I/O on the metric path — and is
|
|
30
|
+
fully unit-testable with hand-constructed predicted-vs-gold pair sets. Only the
|
|
31
|
+
in-repo synthetic-fixture loader (:func:`load_concept_fixture`) touches the
|
|
32
|
+
filesystem. Mirrors :mod:`brain.eval.metrics` (the ranking metrics) in style.
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import re
|
|
37
|
+
from collections.abc import Iterable, Sequence
|
|
38
|
+
from dataclasses import dataclass
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
import yaml
|
|
42
|
+
|
|
43
|
+
from .errors import EvalCorpusError, EvalMetricError
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"ConceptF1Report",
|
|
47
|
+
"ConceptFixtureDoc",
|
|
48
|
+
"ConceptPair",
|
|
49
|
+
"PASS_INVALID_RATE",
|
|
50
|
+
"PASS_MICRO_F1",
|
|
51
|
+
"PASS_PRECISION",
|
|
52
|
+
"PASS_RECALL",
|
|
53
|
+
"PERSON_ENTITY_TYPE",
|
|
54
|
+
"concept_set_micro_f1",
|
|
55
|
+
"load_concept_fixture",
|
|
56
|
+
"normalize_concept_pairs",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
# One concept identity: ``(entity_type, canonical_key)`` — the gate's scoring
|
|
60
|
+
# unit and the catalog's ``UNIQUE(tenant_id, entity_type, canonical_key)`` key.
|
|
61
|
+
ConceptPair = tuple[str, str]
|
|
62
|
+
|
|
63
|
+
# People are excluded from the concept gate (spec §17b decision 2): they come
|
|
64
|
+
# from the participants pipeline for free, not the LLM extractor.
|
|
65
|
+
PERSON_ENTITY_TYPE = "person"
|
|
66
|
+
|
|
67
|
+
# Pass thresholds (spec §17b decision 2). Stored as module constants so the gate
|
|
68
|
+
# test and any future surfacing share one source of truth.
|
|
69
|
+
PASS_MICRO_F1 = 0.80
|
|
70
|
+
PASS_PRECISION = 0.85
|
|
71
|
+
PASS_RECALL = 0.70
|
|
72
|
+
PASS_INVALID_RATE = 0.0
|
|
73
|
+
|
|
74
|
+
# Fixture schema version — bump when the YAML shape changes (mirrors
|
|
75
|
+
# :data:`brain.eval.corpus._CORPUS_VERSION`).
|
|
76
|
+
_FIXTURE_VERSION = 1
|
|
77
|
+
|
|
78
|
+
# Default path to the in-repo synthetic labeled fixture. Resolved relative to the
|
|
79
|
+
# package install root exactly like :data:`brain.eval.corpus._DEFAULT_CORPUS_PATH`
|
|
80
|
+
# (eval/ → brain/ → src/ → repo root, then tests/eval/). Unlike the gitignored
|
|
81
|
+
# golden corpus, THIS fixture is synthetic (no PII) and shipped in-repo.
|
|
82
|
+
_DEFAULT_FIXTURE_PATH: Path = (
|
|
83
|
+
Path(__file__).resolve().parent.parent.parent.parent
|
|
84
|
+
/ "tests"
|
|
85
|
+
/ "eval"
|
|
86
|
+
/ "concept_extraction_fixture.yaml"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class ConceptF1Report:
|
|
92
|
+
"""Type-aware concept-set micro-F1 result + the four gate numbers.
|
|
93
|
+
|
|
94
|
+
``precision`` / ``recall`` / ``micro_f1`` are computed *micro* from the
|
|
95
|
+
summed-across-documents ``true_positives`` / ``false_positives`` /
|
|
96
|
+
``false_negatives``. ``invalid_json_or_schema_rate`` =
|
|
97
|
+
``n_invalid_docs / n_docs``. :attr:`passes` applies the spec §17b decision 2
|
|
98
|
+
thresholds.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
n_docs: int
|
|
102
|
+
n_invalid_docs: int
|
|
103
|
+
true_positives: int
|
|
104
|
+
false_positives: int
|
|
105
|
+
false_negatives: int
|
|
106
|
+
precision: float
|
|
107
|
+
recall: float
|
|
108
|
+
micro_f1: float
|
|
109
|
+
invalid_json_or_schema_rate: float
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def passes(self) -> bool:
|
|
113
|
+
"""True iff all four gate conditions hold (spec §17b decision 2)."""
|
|
114
|
+
return (
|
|
115
|
+
self.micro_f1 >= PASS_MICRO_F1
|
|
116
|
+
and self.precision >= PASS_PRECISION
|
|
117
|
+
and self.recall >= PASS_RECALL
|
|
118
|
+
and self.invalid_json_or_schema_rate <= PASS_INVALID_RATE
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def failing_conditions(self) -> list[str]:
|
|
122
|
+
"""Human-readable list of the gate conditions this report fails.
|
|
123
|
+
|
|
124
|
+
Empty when :attr:`passes` is True. Used by the gate test to build an
|
|
125
|
+
actionable assertion message.
|
|
126
|
+
"""
|
|
127
|
+
failures: list[str] = []
|
|
128
|
+
if self.micro_f1 < PASS_MICRO_F1:
|
|
129
|
+
failures.append(f"micro_f1={self.micro_f1:.4f} < {PASS_MICRO_F1}")
|
|
130
|
+
if self.precision < PASS_PRECISION:
|
|
131
|
+
failures.append(f"precision={self.precision:.4f} < {PASS_PRECISION}")
|
|
132
|
+
if self.recall < PASS_RECALL:
|
|
133
|
+
failures.append(f"recall={self.recall:.4f} < {PASS_RECALL}")
|
|
134
|
+
if self.invalid_json_or_schema_rate > PASS_INVALID_RATE:
|
|
135
|
+
failures.append(
|
|
136
|
+
f"invalid_json_or_schema_rate="
|
|
137
|
+
f"{self.invalid_json_or_schema_rate:.4f} > {PASS_INVALID_RATE}"
|
|
138
|
+
)
|
|
139
|
+
return failures
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass(frozen=True)
|
|
143
|
+
class ConceptFixtureDoc:
|
|
144
|
+
"""One synthetic labeled document from the concept-gate fixture.
|
|
145
|
+
|
|
146
|
+
``text`` is the synthetic body fed to the extractor; ``gold_concepts`` is the
|
|
147
|
+
hand-labeled set of unique ``(entity_type, canonical_key)`` pairs the
|
|
148
|
+
extractor *should* find (people excluded by construction). ``doc_id`` is a
|
|
149
|
+
synthetic identifier for diagnostics only.
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
doc_id: str
|
|
153
|
+
text: str
|
|
154
|
+
gold_concepts: tuple[ConceptPair, ...]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def normalize_concept_pairs(pairs: Iterable[ConceptPair]) -> set[ConceptPair]:
|
|
158
|
+
"""Canonicalize a pair iterable into a deduped, people-excluded pair set.
|
|
159
|
+
|
|
160
|
+
The ``canonical_key`` is lower-cased with ALL separators stripped — the
|
|
161
|
+
strip-all rule that mirrors :func:`brain.graph_rag.extract._canonical_key`
|
|
162
|
+
(Bug B) and the catalog identity, so gold/predicted pairs score on the same
|
|
163
|
+
key shape (a human-readable spaced gold key like ``project aurora`` and the
|
|
164
|
+
extractor's strip-all ``projectaurora`` collapse to one identity). The
|
|
165
|
+
``entity_type`` is lower-cased + whitespace-collapsed (types are single
|
|
166
|
+
words). ``person``-typed pairs and pairs with an empty type or key are dropped
|
|
167
|
+
— so the metric is type-aware *and* people-excluded regardless of what the
|
|
168
|
+
caller passes (spec §17b decision 2).
|
|
169
|
+
"""
|
|
170
|
+
out: set[ConceptPair] = set()
|
|
171
|
+
for entity_type, canonical_key in pairs:
|
|
172
|
+
norm_type = " ".join(str(entity_type).lower().split())
|
|
173
|
+
norm_key = re.sub(r"[\W_]+", "", str(canonical_key).lower())
|
|
174
|
+
if not norm_type or not norm_key:
|
|
175
|
+
continue
|
|
176
|
+
if norm_type == PERSON_ENTITY_TYPE:
|
|
177
|
+
continue
|
|
178
|
+
out.add((norm_type, norm_key))
|
|
179
|
+
return out
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def concept_set_micro_f1(
|
|
183
|
+
*,
|
|
184
|
+
predicted_per_doc: Sequence[Iterable[ConceptPair]],
|
|
185
|
+
gold_per_doc: Sequence[Iterable[ConceptPair]],
|
|
186
|
+
invalid_doc_flags: Sequence[bool] | None = None,
|
|
187
|
+
) -> ConceptF1Report:
|
|
188
|
+
"""Document-level, type-aware concept-set **micro-F1** (spec §17b decision 2).
|
|
189
|
+
|
|
190
|
+
For each document, the predicted and gold pair iterables are canonicalized +
|
|
191
|
+
people-excluded via :func:`normalize_concept_pairs`. A document flagged
|
|
192
|
+
invalid (malformed model output) contributes **no** predicted pairs — all its
|
|
193
|
+
gold pairs become false negatives — and increments the invalid count. True
|
|
194
|
+
positives / false positives / false negatives are summed *across* documents
|
|
195
|
+
(micro), then precision / recall / F1 are computed from those totals.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
predicted_per_doc: Per-document predicted concept pairs (extractor output).
|
|
199
|
+
gold_per_doc: Per-document hand-labeled gold concept pairs. Must be the
|
|
200
|
+
same length as ``predicted_per_doc``.
|
|
201
|
+
invalid_doc_flags: Optional per-document "extraction was malformed" flags
|
|
202
|
+
(same length). When omitted, no document is treated as invalid.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
A frozen :class:`ConceptF1Report`.
|
|
206
|
+
|
|
207
|
+
Raises:
|
|
208
|
+
EvalMetricError: When the input sequences are empty or their lengths
|
|
209
|
+
disagree (a corpus-construction bug).
|
|
210
|
+
"""
|
|
211
|
+
n_docs = len(gold_per_doc)
|
|
212
|
+
if n_docs == 0:
|
|
213
|
+
raise EvalMetricError("concept_set_micro_f1 requires at least one document")
|
|
214
|
+
if len(predicted_per_doc) != n_docs:
|
|
215
|
+
raise EvalMetricError(
|
|
216
|
+
f"predicted_per_doc / gold_per_doc length mismatch: "
|
|
217
|
+
f"{len(predicted_per_doc)} != {n_docs}"
|
|
218
|
+
)
|
|
219
|
+
if invalid_doc_flags is None:
|
|
220
|
+
flags: Sequence[bool] = [False] * n_docs
|
|
221
|
+
else:
|
|
222
|
+
if len(invalid_doc_flags) != n_docs:
|
|
223
|
+
raise EvalMetricError(
|
|
224
|
+
f"invalid_doc_flags length mismatch: {len(invalid_doc_flags)} != {n_docs}"
|
|
225
|
+
)
|
|
226
|
+
flags = invalid_doc_flags
|
|
227
|
+
|
|
228
|
+
true_positives = 0
|
|
229
|
+
false_positives = 0
|
|
230
|
+
false_negatives = 0
|
|
231
|
+
n_invalid_docs = 0
|
|
232
|
+
|
|
233
|
+
for i in range(n_docs):
|
|
234
|
+
gold = normalize_concept_pairs(gold_per_doc[i])
|
|
235
|
+
if flags[i]:
|
|
236
|
+
n_invalid_docs += 1
|
|
237
|
+
predicted: set[ConceptPair] = set() # malformed output → no usable pairs
|
|
238
|
+
else:
|
|
239
|
+
predicted = normalize_concept_pairs(predicted_per_doc[i])
|
|
240
|
+
true_positives += len(predicted & gold)
|
|
241
|
+
false_positives += len(predicted - gold)
|
|
242
|
+
false_negatives += len(gold - predicted)
|
|
243
|
+
|
|
244
|
+
precision = (
|
|
245
|
+
true_positives / (true_positives + false_positives)
|
|
246
|
+
if (true_positives + false_positives) > 0
|
|
247
|
+
else 0.0
|
|
248
|
+
)
|
|
249
|
+
recall = (
|
|
250
|
+
true_positives / (true_positives + false_negatives)
|
|
251
|
+
if (true_positives + false_negatives) > 0
|
|
252
|
+
else 0.0
|
|
253
|
+
)
|
|
254
|
+
# micro-F1 = 2·TP / (2·TP + FP + FN) — algebraically identical to the
|
|
255
|
+
# harmonic mean of the micro precision/recall, computed directly to avoid
|
|
256
|
+
# double float rounding.
|
|
257
|
+
f1_denominator = 2 * true_positives + false_positives + false_negatives
|
|
258
|
+
micro_f1 = (2 * true_positives / f1_denominator) if f1_denominator > 0 else 0.0
|
|
259
|
+
invalid_rate = n_invalid_docs / n_docs
|
|
260
|
+
|
|
261
|
+
return ConceptF1Report(
|
|
262
|
+
n_docs=n_docs,
|
|
263
|
+
n_invalid_docs=n_invalid_docs,
|
|
264
|
+
true_positives=true_positives,
|
|
265
|
+
false_positives=false_positives,
|
|
266
|
+
false_negatives=false_negatives,
|
|
267
|
+
precision=precision,
|
|
268
|
+
recall=recall,
|
|
269
|
+
micro_f1=micro_f1,
|
|
270
|
+
invalid_json_or_schema_rate=invalid_rate,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def load_concept_fixture(path: Path | None = None) -> list[ConceptFixtureDoc]:
|
|
275
|
+
"""Load + validate the synthetic concept-gate fixture YAML.
|
|
276
|
+
|
|
277
|
+
Schema::
|
|
278
|
+
|
|
279
|
+
version: 1
|
|
280
|
+
docs:
|
|
281
|
+
- id: synth-billing-001
|
|
282
|
+
text: "..."
|
|
283
|
+
gold:
|
|
284
|
+
- {type: org, key: acmepay}
|
|
285
|
+
- {type: topic, key: billing}
|
|
286
|
+
|
|
287
|
+
Every ``gold.type`` must be one of the four concept types
|
|
288
|
+
(:data:`brain.graph_rag.extract.CONCEPT_ENTITY_TYPES`) — ``person`` is a
|
|
289
|
+
fixture-authoring error (people are excluded by design), as is any unknown
|
|
290
|
+
type. Gold pairs are returned canonicalized + deduped per document.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
path: Path to the fixture YAML. Defaults to the in-repo synthetic file.
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
Parsed list of :class:`ConceptFixtureDoc`.
|
|
297
|
+
|
|
298
|
+
Raises:
|
|
299
|
+
EvalCorpusError: On a missing / malformed file, version mismatch, missing
|
|
300
|
+
required fields, an empty gold set, or an invalid ``gold.type``.
|
|
301
|
+
"""
|
|
302
|
+
# Lazy import keeps the pure metric path (``from brain.eval import
|
|
303
|
+
# concept_set_micro_f1``) free of any brain.graph_rag dependency; only the
|
|
304
|
+
# fixture loader needs the concept-type allowlist.
|
|
305
|
+
from ..graph_rag.extract import CONCEPT_ENTITY_TYPES
|
|
306
|
+
|
|
307
|
+
if path is None:
|
|
308
|
+
path = _DEFAULT_FIXTURE_PATH
|
|
309
|
+
if not path.exists():
|
|
310
|
+
raise EvalCorpusError(f"concept fixture not found: {path}")
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
314
|
+
except yaml.YAMLError as exc:
|
|
315
|
+
raise EvalCorpusError(f"concept fixture YAML parse error in {path}: {exc}") from exc
|
|
316
|
+
|
|
317
|
+
if not isinstance(raw, dict):
|
|
318
|
+
raise EvalCorpusError(
|
|
319
|
+
f"concept fixture must be a YAML mapping, got {type(raw).__name__}"
|
|
320
|
+
)
|
|
321
|
+
if raw.get("version") != _FIXTURE_VERSION:
|
|
322
|
+
raise EvalCorpusError(
|
|
323
|
+
f"concept fixture version mismatch: expected {_FIXTURE_VERSION}, "
|
|
324
|
+
f"got {raw.get('version')!r}"
|
|
325
|
+
)
|
|
326
|
+
raw_docs = raw.get("docs")
|
|
327
|
+
if not isinstance(raw_docs, list) or not raw_docs:
|
|
328
|
+
raise EvalCorpusError("concept fixture must have a non-empty 'docs' list")
|
|
329
|
+
|
|
330
|
+
docs: list[ConceptFixtureDoc] = []
|
|
331
|
+
for i, entry in enumerate(raw_docs):
|
|
332
|
+
if not isinstance(entry, dict):
|
|
333
|
+
raise EvalCorpusError(
|
|
334
|
+
f"concept fixture doc #{i + 1} must be a mapping, "
|
|
335
|
+
f"got {type(entry).__name__}"
|
|
336
|
+
)
|
|
337
|
+
doc_id = entry.get("id")
|
|
338
|
+
text = entry.get("text")
|
|
339
|
+
gold_raw = entry.get("gold")
|
|
340
|
+
if not isinstance(doc_id, str) or not doc_id.strip():
|
|
341
|
+
raise EvalCorpusError(f"concept fixture doc #{i + 1} missing a string 'id'")
|
|
342
|
+
if not isinstance(text, str) or not text.strip():
|
|
343
|
+
raise EvalCorpusError(
|
|
344
|
+
f"concept fixture doc {doc_id!r} missing a non-empty 'text'"
|
|
345
|
+
)
|
|
346
|
+
if not isinstance(gold_raw, list) or not gold_raw:
|
|
347
|
+
raise EvalCorpusError(
|
|
348
|
+
f"concept fixture doc {doc_id!r} must have a non-empty 'gold' list"
|
|
349
|
+
)
|
|
350
|
+
gold_pairs: list[ConceptPair] = []
|
|
351
|
+
for j, pair in enumerate(gold_raw):
|
|
352
|
+
if not isinstance(pair, dict):
|
|
353
|
+
raise EvalCorpusError(
|
|
354
|
+
f"concept fixture doc {doc_id!r} gold #{j + 1} must be a mapping"
|
|
355
|
+
)
|
|
356
|
+
etype = pair.get("type")
|
|
357
|
+
key = pair.get("key")
|
|
358
|
+
if not isinstance(etype, str) or not isinstance(key, str):
|
|
359
|
+
raise EvalCorpusError(
|
|
360
|
+
f"concept fixture doc {doc_id!r} gold #{j + 1} needs string "
|
|
361
|
+
f"'type' and 'key'"
|
|
362
|
+
)
|
|
363
|
+
norm_type = etype.strip().lower()
|
|
364
|
+
if norm_type not in CONCEPT_ENTITY_TYPES:
|
|
365
|
+
raise EvalCorpusError(
|
|
366
|
+
f"concept fixture doc {doc_id!r} gold #{j + 1} has invalid type "
|
|
367
|
+
f"{etype!r}; allowed: {', '.join(sorted(CONCEPT_ENTITY_TYPES))} "
|
|
368
|
+
f"(people are excluded by design)"
|
|
369
|
+
)
|
|
370
|
+
gold_pairs.append((norm_type, key))
|
|
371
|
+
docs.append(
|
|
372
|
+
ConceptFixtureDoc(
|
|
373
|
+
doc_id=doc_id,
|
|
374
|
+
text=text,
|
|
375
|
+
gold_concepts=tuple(sorted(normalize_concept_pairs(gold_pairs))),
|
|
376
|
+
)
|
|
377
|
+
)
|
|
378
|
+
return docs
|
brain/eval/corpus.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""EvalQuery dataclass and golden-corpus YAML loader."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
from .errors import EvalCorpusError
|
|
9
|
+
|
|
10
|
+
# Path to the local golden-corpus YAML, resolved relative to the package
|
|
11
|
+
# install root. From corpus.py: eval/ → brain/ → src/ → repo root, then
|
|
12
|
+
# descend into tests/eval/. The file is gitignored — each developer
|
|
13
|
+
# authors their own set tailored to their brain's seeded documents (real
|
|
14
|
+
# corpus = real doc IDs). ``load_corpus`` raises ``EvalCorpusError`` when
|
|
15
|
+
# the default path doesn't exist; callers that want to operate without a
|
|
16
|
+
# local corpus should pass ``--corpus`` explicitly.
|
|
17
|
+
_DEFAULT_CORPUS_PATH: Path = (
|
|
18
|
+
Path(__file__).resolve().parent.parent.parent.parent
|
|
19
|
+
/ "tests"
|
|
20
|
+
/ "eval"
|
|
21
|
+
/ "golden_corpus.yaml"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
_VALID_CATEGORIES: frozenset[str] = frozenset(
|
|
25
|
+
(
|
|
26
|
+
"people",
|
|
27
|
+
"meeting",
|
|
28
|
+
"email",
|
|
29
|
+
"interview",
|
|
30
|
+
"source-specific",
|
|
31
|
+
"recency",
|
|
32
|
+
"semantic",
|
|
33
|
+
)
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
_REQUIRED_FIELDS: frozenset[str] = frozenset(("query", "expected_doc_ids", "category"))
|
|
37
|
+
_ALLOWED_FIELDS: frozenset[str] = frozenset(
|
|
38
|
+
("query", "expected_doc_ids", "category", "source_filter", "tag_filter", "since_days", "notes")
|
|
39
|
+
)
|
|
40
|
+
_CORPUS_VERSION = 1
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class EvalQuery:
|
|
45
|
+
"""A single query from the golden eval corpus."""
|
|
46
|
+
|
|
47
|
+
query: str
|
|
48
|
+
expected_doc_ids: list[str] # full UUIDs OR 8-char hex prefixes; resolved at run time
|
|
49
|
+
category: str # one of _VALID_CATEGORIES
|
|
50
|
+
source_filter: str | None = None # mirrors hybrid_search's source_kind kwarg
|
|
51
|
+
tag_filter: str | None = None # mirrors hybrid_search's tag kwarg
|
|
52
|
+
since_days: int | None = None # mirrors hybrid_search's since_days kwarg
|
|
53
|
+
notes: str = "" # human-readable rationale, not graded
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load_corpus(path: Path | None = None) -> list[EvalQuery]:
|
|
57
|
+
"""Load and validate an eval corpus YAML file.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
path: Path to the YAML file. Defaults to ``_DEFAULT_CORPUS_PATH``.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Parsed list of :class:`EvalQuery` objects.
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
EvalCorpusError: On missing/malformed file, version mismatch, unknown
|
|
67
|
+
category, missing required fields, or empty ``expected_doc_ids``.
|
|
68
|
+
"""
|
|
69
|
+
if path is None:
|
|
70
|
+
path = _DEFAULT_CORPUS_PATH
|
|
71
|
+
|
|
72
|
+
if not path.exists():
|
|
73
|
+
raise EvalCorpusError(f"corpus file not found: {path}")
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
77
|
+
except yaml.YAMLError as exc:
|
|
78
|
+
raise EvalCorpusError(f"corpus YAML parse error in {path}: {exc}") from exc
|
|
79
|
+
|
|
80
|
+
if not isinstance(raw, dict):
|
|
81
|
+
raise EvalCorpusError(
|
|
82
|
+
f"corpus file must be a YAML mapping, got {type(raw).__name__}"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
version = raw.get("version")
|
|
86
|
+
if version != _CORPUS_VERSION:
|
|
87
|
+
raise EvalCorpusError(
|
|
88
|
+
f"corpus version mismatch: expected {_CORPUS_VERSION}, got {version!r}. "
|
|
89
|
+
f"Migrate the corpus file to version {_CORPUS_VERSION}."
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
raw_queries = raw.get("queries")
|
|
93
|
+
if not isinstance(raw_queries, list):
|
|
94
|
+
raise EvalCorpusError("corpus file must have a 'queries' list")
|
|
95
|
+
|
|
96
|
+
queries: list[EvalQuery] = []
|
|
97
|
+
for i, entry in enumerate(raw_queries):
|
|
98
|
+
if not isinstance(entry, dict):
|
|
99
|
+
raise EvalCorpusError(
|
|
100
|
+
f"corpus query #{i + 1} must be a YAML mapping, "
|
|
101
|
+
f"got {type(entry).__name__}"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
missing = _REQUIRED_FIELDS - entry.keys()
|
|
105
|
+
if missing:
|
|
106
|
+
raise EvalCorpusError(
|
|
107
|
+
f"corpus query #{i + 1} is missing required field(s): "
|
|
108
|
+
f"{', '.join(sorted(missing))}"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
unknown = entry.keys() - _ALLOWED_FIELDS
|
|
112
|
+
if unknown:
|
|
113
|
+
raise EvalCorpusError(
|
|
114
|
+
f"corpus query #{i + 1} has unknown field(s): "
|
|
115
|
+
f"{', '.join(sorted(unknown))}. "
|
|
116
|
+
f"Allowed: {', '.join(sorted(_ALLOWED_FIELDS))}"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
category = entry["category"]
|
|
120
|
+
if category not in _VALID_CATEGORIES:
|
|
121
|
+
raise EvalCorpusError(
|
|
122
|
+
f"corpus query #{i + 1} has unknown category {category!r}. "
|
|
123
|
+
f"Valid categories: {', '.join(sorted(_VALID_CATEGORIES))}"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
expected_doc_ids = entry["expected_doc_ids"]
|
|
127
|
+
if not isinstance(expected_doc_ids, list):
|
|
128
|
+
raise EvalCorpusError(
|
|
129
|
+
f"corpus query #{i + 1} 'expected_doc_ids' must be a list, "
|
|
130
|
+
f"got {type(expected_doc_ids).__name__}"
|
|
131
|
+
)
|
|
132
|
+
if not expected_doc_ids:
|
|
133
|
+
raise EvalCorpusError(
|
|
134
|
+
f"corpus query #{i + 1} ({entry['query']!r}) has empty "
|
|
135
|
+
f"'expected_doc_ids' — curate the corpus with "
|
|
136
|
+
f"`brain search \"<query>\" --json --limit 20` before running eval"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
since_raw = entry.get("since_days")
|
|
140
|
+
queries.append(
|
|
141
|
+
EvalQuery(
|
|
142
|
+
query=str(entry["query"]),
|
|
143
|
+
expected_doc_ids=[str(doc_id) for doc_id in expected_doc_ids],
|
|
144
|
+
category=category,
|
|
145
|
+
source_filter=entry.get("source_filter") or None,
|
|
146
|
+
tag_filter=entry.get("tag_filter") or None,
|
|
147
|
+
since_days=int(since_raw) if since_raw is not None else None,
|
|
148
|
+
notes=str(entry.get("notes", "")),
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
return queries
|
brain/eval/errors.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Eval-specific exception hierarchy (all inherit from BrainError)."""
|
|
2
|
+
|
|
3
|
+
from brain.errors import BrainError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class EvalError(BrainError):
|
|
7
|
+
"""Base class for all eval-package exceptions."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class EvalMetricError(EvalError):
|
|
11
|
+
"""Raised when a metric function receives invalid inputs (e.g. empty expected set)."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EvalCorpusError(EvalError):
|
|
15
|
+
"""Raised on corpus YAML validation failures (unknown category, missing field, etc.)."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class EvalBaselineError(EvalError):
|
|
19
|
+
"""Raised when a baseline file is missing, unreadable, or malformed."""
|