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,885 @@
|
|
|
1
|
+
"""Gated, validated, versioned concept entity extractor (wave G2-b, GraphRAG).
|
|
2
|
+
|
|
3
|
+
The concept aspect of the entity graph (spec §3 D3, §5, §8). People entities are
|
|
4
|
+
derived *for free* from the participants pipeline (see
|
|
5
|
+
:mod:`brain.graph_rag.reconcile`); **concepts** — topics, projects,
|
|
6
|
+
organizations, tools — are extracted from raw document text by a small local
|
|
7
|
+
Ollama model, then validated/canonicalized/positioned/capped here.
|
|
8
|
+
|
|
9
|
+
**Single responsibility split (SOLID).** The Ollama *transport* (httpx, JSON
|
|
10
|
+
mode, retry, timeout, error mapping) lives in :class:`brain.enrichment.
|
|
11
|
+
OllamaEnricher` — the same place ``summarize`` / ``propose_tags`` live, so this
|
|
12
|
+
module never duplicates a chat client (spec §8: "extract.py — OllamaExtractor
|
|
13
|
+
(validated/versioned/chunked) + canonicalization"). This module owns the
|
|
14
|
+
extraction *logic*:
|
|
15
|
+
|
|
16
|
+
* **Chunked** — long documents are split with the existing paragraph-aware
|
|
17
|
+
:func:`brain.ingest.chunker.chunk_text` (token-budgeted via the enricher's
|
|
18
|
+
``count_tokens``) and the model is called once per chunk, so a document larger
|
|
19
|
+
than the model context is still fully covered.
|
|
20
|
+
* **Validated** — every model entry is checked deterministically: it must be a
|
|
21
|
+
``{"name": str, "type": str}`` object whose ``type`` is one of the four
|
|
22
|
+
concept types (:data:`CONCEPT_ENTITY_TYPES`). People and unknown types are
|
|
23
|
+
dropped (spec §17b decision 2: "people excluded"). Malformed entries are
|
|
24
|
+
skipped, never fatal.
|
|
25
|
+
* **Presence-validated (v3)** — a small model on an entity-*sparse* document
|
|
26
|
+
tends to copy the prompt's illustrative names or emit its own reasoning text
|
|
27
|
+
as "entities". So every candidate whose name does not actually appear in the
|
|
28
|
+
source document (separator-normalized substring match) is dropped, regardless
|
|
29
|
+
of prompt. This is the robust kill for few-shot prompt-example leakage; a
|
|
30
|
+
reasoning-text reject (sentence-length / meta-commentary patterns) and a
|
|
31
|
+
generic structural-noise filter (``PDF`` / ``Chapter 24`` / ``Section 3`` /
|
|
32
|
+
page + format words) back it up.
|
|
33
|
+
* **One type per name (v3)** — when the model emits the *same* canonical name
|
|
34
|
+
under multiple types (a standards body as both ``org`` and ``topic``; a
|
|
35
|
+
numbered standard duplicated across ``topic`` / ``org`` / ``tool``), the
|
|
36
|
+
duplicates are collapsed to a single node keeping the highest-precedence type
|
|
37
|
+
(:data:`_TYPE_PRECEDENCE`: person > org > project > tool > topic), so the
|
|
38
|
+
graph (and the communities derived from it) is not fragmented.
|
|
39
|
+
* **Canonicalized** — ``canonical_key`` is ``name`` lower-cased with ALL
|
|
40
|
+
separators stripped (strip-all, Bug B), so separator variants of one concept
|
|
41
|
+
(``AcmePlatform`` / ``acme-platform`` / ``Acme Platform``) share one key and
|
|
42
|
+
stop fragmenting the catalog. Entities dedup on
|
|
43
|
+
``(entity_type, canonical_key)`` — the same key the eval gate scores
|
|
44
|
+
(spec §17b decision 2) and the catalog uniqueness key
|
|
45
|
+
``UNIQUE(tenant_id, entity_type, canonical_key)`` (spec §5).
|
|
46
|
+
* **Positioned** — each entity's raw-text *word-index* positions are located in
|
|
47
|
+
the full document (chunker-independent, per spec §4 D4: ``pos`` is "a
|
|
48
|
+
token/word index for raw-text concepts"). These feed
|
|
49
|
+
:func:`brain.graph_rag.cooccur.cooccurrence_counts` in G2-c.
|
|
50
|
+
* **Capped** — the per-document distinct-entity count is bounded by
|
|
51
|
+
``BRAIN_GRAPH_MAX_ENTITIES_PER_DOC`` (spec §10), kept by mention frequency
|
|
52
|
+
(ties broken by ``canonical_key``), mirroring
|
|
53
|
+
:func:`brain.graph_rag.cooccur._apply_entity_cap`.
|
|
54
|
+
* **Never-raise** — Ollama down / timeout / invalid JSON yields an **empty
|
|
55
|
+
list + a WARN**, never an exception (spec §7 / §17b decision 7 discipline), so
|
|
56
|
+
the gated G2-c ingest hook can never be broken by a flaky extractor.
|
|
57
|
+
|
|
58
|
+
**Versioning.** :data:`EXTRACTOR_VERSION` is the extraction-algorithm version.
|
|
59
|
+
:attr:`OllamaExtractor.version` folds it together with the model fingerprint
|
|
60
|
+
(``"<model>@<ver>"``), so a model swap *or* an algorithm bump flips the
|
|
61
|
+
``graph_index_state.extractor_ver`` watermark and forces G2-c to re-extract
|
|
62
|
+
(spec §7 step 1). The ``graph_entity_mentions.source`` provenance string
|
|
63
|
+
(spec §5a) is ``"extractor:<model>@<ver>"`` — i.e. ``f"extractor:{version}"`` —
|
|
64
|
+
constructed by the G2-c reconcile from this same property.
|
|
65
|
+
|
|
66
|
+
**Gating.** Concepts are default-OFF (``BRAIN_GRAPH_CONCEPTS=false``); this
|
|
67
|
+
module is dormant until G2-c wires it behind that flag. G2-b builds and
|
|
68
|
+
unit-tests the extractor in isolation only.
|
|
69
|
+
"""
|
|
70
|
+
from __future__ import annotations
|
|
71
|
+
|
|
72
|
+
import logging
|
|
73
|
+
import re
|
|
74
|
+
from dataclasses import dataclass
|
|
75
|
+
from typing import Any, Protocol, runtime_checkable
|
|
76
|
+
|
|
77
|
+
from ..config import Config
|
|
78
|
+
from ..enrichment import OllamaEnricher
|
|
79
|
+
from ..errors import EnrichmentError, OllamaUnavailable
|
|
80
|
+
from .cooccur import DEFAULT_MAX_ENTITIES_PER_DOC
|
|
81
|
+
|
|
82
|
+
_logger = logging.getLogger(__name__)
|
|
83
|
+
|
|
84
|
+
__all__ = [
|
|
85
|
+
"CONCEPT_ENTITY_TYPES",
|
|
86
|
+
"EXTRACTOR_VERSION",
|
|
87
|
+
"EntityExtractor",
|
|
88
|
+
"ExtractedEntity",
|
|
89
|
+
"OllamaExtractor",
|
|
90
|
+
"make_extractor",
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
# Extraction-algorithm version. Folded into :attr:`OllamaExtractor.version`,
|
|
94
|
+
# which feeds ``graph_index_state.extractor_ver`` (spec §7). Bump when the
|
|
95
|
+
# prompt / validation / canonicalization semantics change so reconcile
|
|
96
|
+
# re-extracts affected documents.
|
|
97
|
+
#
|
|
98
|
+
# concepts-v4 (2026-05-24, perf Fix C): the extractor now applies a configurable
|
|
99
|
+
# input head cap (``BRAIN_GRAPH_EXTRACT_MAX_INPUT_TOKENS``, default 8000) BEFORE
|
|
100
|
+
# chunking, so a long document is no longer extracted in full. This changes the
|
|
101
|
+
# extraction output for docs past the cap, so the watermark MUST change to force a
|
|
102
|
+
# ``--backfill`` re-extraction.
|
|
103
|
+
#
|
|
104
|
+
# concepts-v5 (2026-05-25, Phase B): (1) ``_STRUCTURAL_ENUM_RE`` extended with
|
|
105
|
+
# number-words (zero…ten) so "section one" / "version two" are dropped by the
|
|
106
|
+
# existing ``_is_noise_key`` filter without a new broad regex; (2)
|
|
107
|
+
# ``OllamaExtractor`` gains an ``extra_stopwords`` parameter (operator-curated
|
|
108
|
+
# canonical keys dropped in ``_finalize`` after the generic noise filter). Real
|
|
109
|
+
# stopwords are employer-specific (rule 15) and default to the empty set.
|
|
110
|
+
#
|
|
111
|
+
# concepts-v6 (2026-06-02, Bug B): ``_canonical_key`` is now STRIP-ALL —
|
|
112
|
+
# lower-cased with every separator removed (``re.sub(r"[\W_]+", "", …)``) instead
|
|
113
|
+
# of whitespace-collapse only. Separator variants of one concept (``AcmePlatform``
|
|
114
|
+
# / ``acme-platform`` / ``Acme Platform`` / ``acmeplatform``) collapse to a single
|
|
115
|
+
# key and stop fragmenting the catalog. The identity key changes shape, so the
|
|
116
|
+
# watermark MUST change to force a ``--backfill`` re-extraction that re-keys and
|
|
117
|
+
# merges existing separator-variant rows. Dependents kept consistent:
|
|
118
|
+
# ``_normalize_for_presence`` also strips all separators (presence stays
|
|
119
|
+
# consistent with the new key, both directions); ``_STRUCTURAL_ENUM_RE`` matches
|
|
120
|
+
# ``\s*`` so glued forms (``chapter24`` / ``sectionone``) are still filtered; and
|
|
121
|
+
# ``_dedupe_project_substrings`` + curated ``extra_stopwords`` operate on
|
|
122
|
+
# display-name word tokens / normalized keys rather than the now-wordless key.
|
|
123
|
+
EXTRACTOR_VERSION = "concepts-v6"
|
|
124
|
+
|
|
125
|
+
# The concept entity types the extractor emits (spec §5 ``entity_type CHECK``
|
|
126
|
+
# minus ``person``). People are derived from the participants pipeline and are
|
|
127
|
+
# explicitly NOT extracted here (spec §17b decision 2: "people excluded"); any
|
|
128
|
+
# model entry typed ``person`` (or anything off this list) is dropped.
|
|
129
|
+
CONCEPT_ENTITY_TYPES = frozenset({"topic", "project", "org", "tool"})
|
|
130
|
+
|
|
131
|
+
# Near-synonym TYPE labels a small model commonly emits for the four canonical
|
|
132
|
+
# concept types. Normalized to the canonical type BEFORE the allowlist check in
|
|
133
|
+
# :func:`_validate_entry`, so a valid extraction tagged ``"organization"`` /
|
|
134
|
+
# ``"software"`` / ``"initiative"`` / ``"theme"`` is recovered rather than
|
|
135
|
+
# silently dropped (a real recall loss the gate exposed). This is keyed strictly
|
|
136
|
+
# on the TYPE label — never on the entity NAME (no vendor/name allowlist, which
|
|
137
|
+
# would overfit the synthetic gate fixture).
|
|
138
|
+
#
|
|
139
|
+
# Deliberately conservative: ONLY unambiguous type-label synonyms are mapped.
|
|
140
|
+
# Ambiguous words (``platform`` / ``technology`` / ``domain`` / ``area`` /
|
|
141
|
+
# ``language`` / ``infrastructure`` / ``app``) are intentionally EXCLUDED — a
|
|
142
|
+
# real-corpus entity mislabeled with one of those is better dropped than
|
|
143
|
+
# silently mis-typed (Codex review).
|
|
144
|
+
_TYPE_LABEL_SYNONYMS: dict[str, str] = {
|
|
145
|
+
"organization": "org",
|
|
146
|
+
"organisation": "org",
|
|
147
|
+
"company": "org",
|
|
148
|
+
"companies": "org",
|
|
149
|
+
"vendor": "org",
|
|
150
|
+
"institution": "org",
|
|
151
|
+
"corporation": "org",
|
|
152
|
+
"software": "tool",
|
|
153
|
+
"framework": "tool",
|
|
154
|
+
"library": "tool",
|
|
155
|
+
"application": "tool",
|
|
156
|
+
"initiative": "project",
|
|
157
|
+
"effort": "project",
|
|
158
|
+
"program": "project",
|
|
159
|
+
"programme": "project",
|
|
160
|
+
"projects": "project",
|
|
161
|
+
"theme": "topic",
|
|
162
|
+
"subject": "topic",
|
|
163
|
+
"topics": "topic",
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
# Minimum canonical-key length (characters). A floor of 2 drops single-character
|
|
167
|
+
# noise while KEEPING short acronyms / tool names (a 2-char language, ``ml``,
|
|
168
|
+
# ``ci``…). Deliberately not 3 — a 3-char floor silently kills valid 2-char
|
|
169
|
+
# acronyms in the real corpus (Codex review, recall-protective).
|
|
170
|
+
_MIN_CANONICAL_KEY_LEN = 2
|
|
171
|
+
|
|
172
|
+
# Generic structural / meeting nouns that are never a useful standalone concept.
|
|
173
|
+
# EXACT canonical-key match only, and intentionally GENERIC (NOT this fixture's
|
|
174
|
+
# specific distractor terms) so it never blocks a legitimate domain concept in
|
|
175
|
+
# the real 1195-doc corpus (Codex review: a fixture-derived blocklist overfits
|
|
176
|
+
# the precision side). Keep this list small and obviously-generic.
|
|
177
|
+
_GENERIC_STOP_KEYS: frozenset[str] = frozenset({
|
|
178
|
+
"platform",
|
|
179
|
+
"service",
|
|
180
|
+
"services",
|
|
181
|
+
"system",
|
|
182
|
+
"systems",
|
|
183
|
+
"stack",
|
|
184
|
+
"team",
|
|
185
|
+
"teams",
|
|
186
|
+
"group",
|
|
187
|
+
"groups",
|
|
188
|
+
"meeting",
|
|
189
|
+
"meetings",
|
|
190
|
+
"call",
|
|
191
|
+
"calls",
|
|
192
|
+
"sync",
|
|
193
|
+
"standup",
|
|
194
|
+
"review",
|
|
195
|
+
"reviews",
|
|
196
|
+
"update",
|
|
197
|
+
"updates",
|
|
198
|
+
"discussion",
|
|
199
|
+
"workshop",
|
|
200
|
+
"sprint",
|
|
201
|
+
"document",
|
|
202
|
+
"note",
|
|
203
|
+
"notes",
|
|
204
|
+
"report",
|
|
205
|
+
"dashboard",
|
|
206
|
+
"dashboards",
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
# Document-structure / file-format noise that is never a standalone concept
|
|
210
|
+
# (audit B.5: ``PDF`` / ``Chapter 24`` / ``<Body> Standard`` extracted as
|
|
211
|
+
# topics). EXACT canonical-key match, extends :data:`_GENERIC_STOP_KEYS`, and
|
|
212
|
+
# kept deliberately GENERIC — only format + document-structure words, never a
|
|
213
|
+
# real product / standards-body / domain name (a corpus-derived blocklist would
|
|
214
|
+
# overfit precision and risk dropping legitimate concepts). "standard" /
|
|
215
|
+
# "standards" sit here because the audit observed a numbered-standards family
|
|
216
|
+
# fragmenting the graph; a real domain concept is virtually never the bare word.
|
|
217
|
+
_STRUCTURAL_STOP_KEYS: frozenset[str] = frozenset({
|
|
218
|
+
"pdf",
|
|
219
|
+
"docx",
|
|
220
|
+
"doc",
|
|
221
|
+
"html",
|
|
222
|
+
"csv",
|
|
223
|
+
"json",
|
|
224
|
+
"xml",
|
|
225
|
+
"page",
|
|
226
|
+
"pages",
|
|
227
|
+
"chapter",
|
|
228
|
+
"chapters",
|
|
229
|
+
"section",
|
|
230
|
+
"sections",
|
|
231
|
+
"appendix",
|
|
232
|
+
"appendices",
|
|
233
|
+
"figure",
|
|
234
|
+
"figures",
|
|
235
|
+
"table",
|
|
236
|
+
"tables",
|
|
237
|
+
"exhibit",
|
|
238
|
+
"exhibits",
|
|
239
|
+
"paragraph",
|
|
240
|
+
"paragraphs",
|
|
241
|
+
"footnote",
|
|
242
|
+
"footnotes",
|
|
243
|
+
"heading",
|
|
244
|
+
"headings",
|
|
245
|
+
"introduction",
|
|
246
|
+
"conclusion",
|
|
247
|
+
"abstract",
|
|
248
|
+
"preface",
|
|
249
|
+
"glossary",
|
|
250
|
+
"index",
|
|
251
|
+
"contents",
|
|
252
|
+
"attachment",
|
|
253
|
+
"attachments",
|
|
254
|
+
"screenshot",
|
|
255
|
+
"screenshots",
|
|
256
|
+
"standard",
|
|
257
|
+
"standards",
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
# A structural reference of the form "<structural noun><enumerator>" — e.g.
|
|
261
|
+
# "chapter 24", "section 3", "page 12", "figure 2", "appendix a", "standard 72".
|
|
262
|
+
# Matches a document-structure noun followed by a number / roman numeral / single
|
|
263
|
+
# letter; generic by construction, never a real entity name. Applied to the
|
|
264
|
+
# already-lower-cased canonical key — which is now STRIP-ALL (Bug B), so the
|
|
265
|
+
# separator between noun and enumerator is gone (``chapter24`` / ``sectionone``).
|
|
266
|
+
# The whitespace matcher is therefore ``\s*`` (zero-or-more), keeping BOTH the
|
|
267
|
+
# historical spaced form and the post-strip-all glued form filtered.
|
|
268
|
+
# DO NOT revert ``\s*`` to ``\s+``: strip-all keys never contain whitespace, so
|
|
269
|
+
# ``\s+`` would make this filter dead (nothing would ever match). The trade-off is
|
|
270
|
+
# that a short name colliding with a noun-prefix + single letter (e.g. "vera" =>
|
|
271
|
+
# ver+a) is filtered as collateral — accepted, since the filter is generic by
|
|
272
|
+
# construction and such collisions are rare in the real corpus.
|
|
273
|
+
_STRUCTURAL_ENUM_RE = re.compile(
|
|
274
|
+
r"^(?:chapter|section|page|figure|fig|table|appendix|exhibit|paragraph|"
|
|
275
|
+
r"para|footnote|clause|article|item|part|volume|vol|step|standard|"
|
|
276
|
+
r"version|ver|revision|rev)\s*"
|
|
277
|
+
r"(?:\d+|[ivxlcdm]+|[a-z]|"
|
|
278
|
+
r"zero|one|two|three|four|five|six|seven|eight|nine|ten)$"
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
# Maximum word count for an entity NAME. Concept names are short (the gate's
|
|
282
|
+
# longest gold entity is two words); a longer "name" is almost always the
|
|
283
|
+
# model's reasoning text / a sentence rather than an entity (audit B.2). A
|
|
284
|
+
# generous cap of 6 protects recall on legitimate multi-word concepts.
|
|
285
|
+
_MAX_ENTITY_WORDS = 6
|
|
286
|
+
|
|
287
|
+
# Meta-commentary / reasoning-text markers a small model emits AS an entity name
|
|
288
|
+
# on entity-sparse documents (audit B.2: e.g. "<X> is not present in the text.
|
|
289
|
+
# However, …"). Matched against the candidate name; a hit means it is the
|
|
290
|
+
# model's prose, not a concept, so it is dropped. Generic phrasing only.
|
|
291
|
+
_REASONING_PATTERNS = re.compile(
|
|
292
|
+
r"(?i)("
|
|
293
|
+
r"\bis not present\b|\bare not present\b|\bnot present in\b|"
|
|
294
|
+
r"\bdoes not appear\b|\bdo not appear\b|\bnot mentioned\b|"
|
|
295
|
+
r"\bno (?:clear |concept )*entit(?:y|ies)\b|"
|
|
296
|
+
r"\bthere (?:is|are) no\b|"
|
|
297
|
+
r"\bcannot (?:find|identify|extract)\b|\bunable to\b|"
|
|
298
|
+
r"\bthe (?:text|document|passage) (?:does|contains|has|mentions|states|"
|
|
299
|
+
r"describes|discusses)\b|"
|
|
300
|
+
r"\bn/a\b|\bnone\b|\bhowever\b"
|
|
301
|
+
r")"
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
# Type precedence for cross-type collapse (audit B.3/B.4): when the SAME
|
|
305
|
+
# canonical name is emitted under multiple types, keep ONE node with the
|
|
306
|
+
# highest-precedence type and drop the rest. ``person`` ranks first for
|
|
307
|
+
# completeness (a name confusable with a person should win person), though
|
|
308
|
+
# people never reach :func:`_finalize` — they are dropped in
|
|
309
|
+
# :func:`_validate_entry`. Among concept types: a hosted provider is an ``org``
|
|
310
|
+
# before a ``topic``; a named effort is a ``project`` before a ``tool``.
|
|
311
|
+
_TYPE_PRECEDENCE: tuple[str, ...] = ("person", "org", "project", "tool", "topic")
|
|
312
|
+
_TYPE_RANK: dict[str, int] = {t: rank for rank, t in enumerate(_TYPE_PRECEDENCE)}
|
|
313
|
+
|
|
314
|
+
# Separator pattern shared by the canonical key and the presence check: every run
|
|
315
|
+
# of non-alphanumeric characters (whitespace, hyphens, dots, underscores, special
|
|
316
|
+
# characters). Both callers REMOVE these (strip-all, Bug B), so "back-pressure" /
|
|
317
|
+
# "back_pressure" / "Back Pressure" / "BackPressure" all normalize to
|
|
318
|
+
# "backpressure" — a hyphenated concept present in the text is not spuriously
|
|
319
|
+
# dropped, and separator variants of one concept share a single canonical key.
|
|
320
|
+
_SEPARATOR_RE = re.compile(r"[\W_]+", re.UNICODE)
|
|
321
|
+
|
|
322
|
+
# Per-chunk token budget for the LLM extraction calls. A document longer than
|
|
323
|
+
# this is split into multiple chunks (one model call each). Independent of the
|
|
324
|
+
# enrich-summary budget (no enrich<->graph config coupling); not env-tunable in
|
|
325
|
+
# G2 (YAGNI — promote to a knob only if the eval gate needs it).
|
|
326
|
+
_DEFAULT_CHUNK_TARGET_TOKENS = 1500
|
|
327
|
+
|
|
328
|
+
# Overlap between consecutive extraction chunks. A small overlap reduces the
|
|
329
|
+
# chance an entity straddling a chunk boundary is missed; cross-chunk repeats
|
|
330
|
+
# are harmless (dedup collapses them, positions are computed over the full doc).
|
|
331
|
+
_DEFAULT_CHUNK_OVERLAP_TOKENS = 100
|
|
332
|
+
|
|
333
|
+
# Raw-text word tokenizer for positions + canonicalization. ``\w+`` over the
|
|
334
|
+
# lower-cased text yields chunker-independent word ordinals (spec §4 D4).
|
|
335
|
+
_WORD_RE = re.compile(r"\w+", re.UNICODE)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
@dataclass(frozen=True)
|
|
339
|
+
class ExtractedEntity:
|
|
340
|
+
"""One validated, canonicalized concept entity extracted from a document.
|
|
341
|
+
|
|
342
|
+
The pre-upsert input to the G2-c concept reconcile (no ``tenant_id`` /
|
|
343
|
+
``id`` — those are assigned when the catalog row is upserted, exactly as a
|
|
344
|
+
:class:`brain.graph_rag.reconcile.ResolvedPerson` carries no id).
|
|
345
|
+
|
|
346
|
+
``canonical_key`` is the dedup identity (``name`` lower-cased with all
|
|
347
|
+
separators stripped, Bug B) — unique per ``(entity_type, canonical_key)`` and
|
|
348
|
+
the key the eval gate scores. ``display_name`` is the surface form for the
|
|
349
|
+
catalog ``name`` / AGE vertex label. ``positions`` are the entity's
|
|
350
|
+
raw-text word-index occurrences over the *whole* document (empty when the
|
|
351
|
+
model named a concept that does not appear literally in the text); they feed
|
|
352
|
+
:func:`brain.graph_rag.cooccur.cooccurrence_counts` in G2-c.
|
|
353
|
+
``mention_count`` (``>= 1``) backs the ``graph_entity_mentions`` row.
|
|
354
|
+
"""
|
|
355
|
+
|
|
356
|
+
entity_type: str
|
|
357
|
+
canonical_key: str
|
|
358
|
+
display_name: str
|
|
359
|
+
positions: tuple[int, ...] = ()
|
|
360
|
+
mention_count: int = 1
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@runtime_checkable
|
|
364
|
+
class EntityExtractor(Protocol):
|
|
365
|
+
"""Resolve one document's concept entities (dependency-inversion seam).
|
|
366
|
+
|
|
367
|
+
Narrow Protocol mirroring the embedder/enricher pattern: a ``version``
|
|
368
|
+
property (threaded into the ``graph_index_state.extractor_ver`` watermark)
|
|
369
|
+
and an ``extract`` method. The default implementation
|
|
370
|
+
(:class:`OllamaExtractor`) wraps a local-Ollama model; G2-c depends only on
|
|
371
|
+
this Protocol and tests inject fakes.
|
|
372
|
+
"""
|
|
373
|
+
|
|
374
|
+
@property
|
|
375
|
+
def version(self) -> str:
|
|
376
|
+
"""Watermark version string for ``graph_index_state.extractor_ver``."""
|
|
377
|
+
...
|
|
378
|
+
|
|
379
|
+
def extract(self, text: str) -> list[ExtractedEntity]:
|
|
380
|
+
"""Return the document's deduped, capped concept entities (never raises)."""
|
|
381
|
+
...
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
class OllamaExtractor:
|
|
385
|
+
"""Local-Ollama concept extractor — validated, versioned, chunked.
|
|
386
|
+
|
|
387
|
+
Composes a :class:`brain.enrichment.OllamaEnricher` for the chat transport
|
|
388
|
+
(``extract_entities`` round-trip + ``count_tokens`` for chunking + the model
|
|
389
|
+
fingerprint) and adds the extraction logic on top. Construct via
|
|
390
|
+
:func:`make_extractor`; tests inject an enricher backed by an
|
|
391
|
+
``httpx.MockTransport`` (no monkey-patching).
|
|
392
|
+
"""
|
|
393
|
+
|
|
394
|
+
def __init__(
|
|
395
|
+
self,
|
|
396
|
+
*,
|
|
397
|
+
enricher: OllamaEnricher,
|
|
398
|
+
max_entities: int | None = DEFAULT_MAX_ENTITIES_PER_DOC,
|
|
399
|
+
chunk_target_tokens: int = _DEFAULT_CHUNK_TARGET_TOKENS,
|
|
400
|
+
chunk_overlap_tokens: int = _DEFAULT_CHUNK_OVERLAP_TOKENS,
|
|
401
|
+
max_input_tokens: int | None = None,
|
|
402
|
+
extra_stopwords: frozenset[str] = frozenset(),
|
|
403
|
+
) -> None:
|
|
404
|
+
if max_entities is not None and max_entities < 1:
|
|
405
|
+
raise ValueError(
|
|
406
|
+
f"max_entities must be a positive integer or None (got {max_entities})"
|
|
407
|
+
)
|
|
408
|
+
if chunk_target_tokens < 1:
|
|
409
|
+
raise ValueError(
|
|
410
|
+
f"chunk_target_tokens must be a positive integer (got {chunk_target_tokens})"
|
|
411
|
+
)
|
|
412
|
+
if chunk_overlap_tokens < 0:
|
|
413
|
+
raise ValueError(
|
|
414
|
+
"chunk_overlap_tokens must be a non-negative integer "
|
|
415
|
+
f"(got {chunk_overlap_tokens})"
|
|
416
|
+
)
|
|
417
|
+
if max_input_tokens is not None and max_input_tokens < 1:
|
|
418
|
+
raise ValueError(
|
|
419
|
+
"max_input_tokens must be a positive integer or None "
|
|
420
|
+
f"(got {max_input_tokens})"
|
|
421
|
+
)
|
|
422
|
+
self._enricher = enricher
|
|
423
|
+
self._max_entities = max_entities
|
|
424
|
+
self._chunk_target_tokens = chunk_target_tokens
|
|
425
|
+
self._chunk_overlap_tokens = chunk_overlap_tokens
|
|
426
|
+
# Perf Fix C: head cap (in cl100k_base tokens) applied to the document
|
|
427
|
+
# body before chunking. ``None`` == no cap (the whole document is
|
|
428
|
+
# extracted — the historical behavior and the test default). Production
|
|
429
|
+
# threads ``BRAIN_GRAPH_EXTRACT_MAX_INPUT_TOKENS`` via ``make_extractor``.
|
|
430
|
+
self._max_input_tokens = max_input_tokens
|
|
431
|
+
# Phase B: operator-curated canonical keys dropped in ``_finalize`` after
|
|
432
|
+
# the generic noise filter. Default empty (real terms are employer-specific,
|
|
433
|
+
# rule 15). Threaded from ``cfg.graph_extract_stopwords`` by
|
|
434
|
+
# :func:`make_extractor`. Bug B: normalize each curated stopword through the
|
|
435
|
+
# strip-all :func:`_canonical_key` so an operator-friendly spaced/hyphenated
|
|
436
|
+
# form ("bar baz") matches the strip-all candidate key ("barbaz"); empties
|
|
437
|
+
# (no alphanumeric content) are dropped.
|
|
438
|
+
self._extra_stopwords = frozenset(
|
|
439
|
+
key for word in extra_stopwords if (key := _canonical_key(word))
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
@property
|
|
443
|
+
def version(self) -> str:
|
|
444
|
+
"""``"<model>@<ver>"`` — the watermark version (spec §7).
|
|
445
|
+
|
|
446
|
+
Folds the model fingerprint together with :data:`EXTRACTOR_VERSION` so a
|
|
447
|
+
model swap *or* an algorithm bump re-extracts. G2-c uses this both as the
|
|
448
|
+
``graph_index_state.extractor_ver`` value and (as ``f"extractor:{version}"``)
|
|
449
|
+
the ``graph_entity_mentions.source`` provenance (spec §5a).
|
|
450
|
+
"""
|
|
451
|
+
return f"{self._enricher.model}@{EXTRACTOR_VERSION}"
|
|
452
|
+
|
|
453
|
+
def extract(self, text: str) -> list[ExtractedEntity]:
|
|
454
|
+
"""Extract one document's concept entities. Never raises.
|
|
455
|
+
|
|
456
|
+
Optionally head-caps ``text`` to the first ``max_input_tokens`` tokens
|
|
457
|
+
(perf Fix C; ``None`` = no cap), then chunks it, calls the model once per
|
|
458
|
+
chunk, validates + collects every well-formed candidate, then dedups on
|
|
459
|
+
``(entity_type, canonical_key)``, locates raw-text positions over the
|
|
460
|
+
(capped) document, and applies the per-doc cap. On Ollama unavailability
|
|
461
|
+
the whole extraction returns ``[]`` (+ WARN); a single chunk that returns
|
|
462
|
+
malformed JSON is skipped (+ WARN) and the remaining chunks still
|
|
463
|
+
contribute.
|
|
464
|
+
"""
|
|
465
|
+
text = text.strip()
|
|
466
|
+
if not text:
|
|
467
|
+
return []
|
|
468
|
+
|
|
469
|
+
# Perf Fix C: head-cap the input BEFORE chunking. The model is called once
|
|
470
|
+
# per chunk, so a long document drives a heavy tail of LLM calls; capping
|
|
471
|
+
# to the first ``self._max_input_tokens`` tokens bounds that tail. The cap
|
|
472
|
+
# uses the enricher's tokenizer (the same ``count_tokens`` path the chunker
|
|
473
|
+
# below budgets with), so the truncation boundary and the chunk sizing
|
|
474
|
+
# agree. ``None`` disables the cap (whole document extracted). KISS: a
|
|
475
|
+
# plain HEAD cap — head+tail sampling (to also catch concepts that appear
|
|
476
|
+
# only deep in a long transcript) is a deliberate FUTURE tuning option, not
|
|
477
|
+
# built here.
|
|
478
|
+
if self._max_input_tokens is not None:
|
|
479
|
+
text = self._enricher.truncate_to_tokens(text, self._max_input_tokens)
|
|
480
|
+
|
|
481
|
+
# Late import keeps :mod:`brain.graph_rag` import-cheap and avoids pulling
|
|
482
|
+
# the heavy :mod:`brain.ingest` package in at module load (mirrors
|
|
483
|
+
# ``reconcile.py``'s late import of ``brain.wiki.build_people``).
|
|
484
|
+
from ..ingest.chunker import chunk_text
|
|
485
|
+
|
|
486
|
+
chunks = chunk_text(
|
|
487
|
+
text,
|
|
488
|
+
target_tokens=self._chunk_target_tokens,
|
|
489
|
+
overlap_tokens=self._chunk_overlap_tokens,
|
|
490
|
+
count_tokens=self._enricher.count_tokens,
|
|
491
|
+
)
|
|
492
|
+
candidates: list[tuple[str, str]] = []
|
|
493
|
+
for chunk in chunks:
|
|
494
|
+
try:
|
|
495
|
+
raw_entities = self._enricher.extract_entities(chunk.content)
|
|
496
|
+
except OllamaUnavailable as exc:
|
|
497
|
+
# Server down — no chunk can succeed; abort the whole extraction.
|
|
498
|
+
_logger.warning(
|
|
499
|
+
"concept extraction aborted: Ollama unavailable (%s); "
|
|
500
|
+
"returning no entities",
|
|
501
|
+
exc,
|
|
502
|
+
)
|
|
503
|
+
return []
|
|
504
|
+
except EnrichmentError as exc:
|
|
505
|
+
# Malformed JSON after retry on THIS chunk; skip it, keep going.
|
|
506
|
+
_logger.warning(
|
|
507
|
+
"concept extraction: chunk %d failed (%s); skipping chunk",
|
|
508
|
+
chunk.index,
|
|
509
|
+
exc,
|
|
510
|
+
)
|
|
511
|
+
continue
|
|
512
|
+
for entry in raw_entities:
|
|
513
|
+
validated = _validate_entry(entry)
|
|
514
|
+
if validated is not None:
|
|
515
|
+
candidates.append(validated)
|
|
516
|
+
|
|
517
|
+
return self._finalize(candidates, text)
|
|
518
|
+
|
|
519
|
+
def _finalize(
|
|
520
|
+
self, candidates: list[tuple[str, str]], text: str
|
|
521
|
+
) -> list[ExtractedEntity]:
|
|
522
|
+
"""Repair, filter, dedup, position, cap, and order the candidates."""
|
|
523
|
+
if not candidates:
|
|
524
|
+
return []
|
|
525
|
+
doc_words = _tokenize_words(text)
|
|
526
|
+
normalized_text = _normalize_for_presence(text)
|
|
527
|
+
# Dedup on (entity_type, canonical_key); keep the first-seen surface form
|
|
528
|
+
# as the display name. Iteration order over the candidate list is
|
|
529
|
+
# deterministic (chunk order, then in-chunk order), so the kept surface
|
|
530
|
+
# form is stable. Per candidate, in order: restore a stripped "Project"
|
|
531
|
+
# prefix when the document names the project that way (fixes an exact-key
|
|
532
|
+
# miss); drop the model's reasoning text / meta-commentary (B.2); drop
|
|
533
|
+
# too-short, generic, or structural-noise keys (B.5 precision); and drop
|
|
534
|
+
# any name that does not actually appear in the source text (B.1 presence
|
|
535
|
+
# validation — the robust kill for few-shot prompt-example leakage).
|
|
536
|
+
seen: dict[tuple[str, str], str] = {}
|
|
537
|
+
for entity_type, raw_display_name in candidates:
|
|
538
|
+
display_name = _repair_project_prefix(entity_type, raw_display_name, text)
|
|
539
|
+
if _is_reasoning_text(display_name):
|
|
540
|
+
continue
|
|
541
|
+
canonical_key = _canonical_key(display_name)
|
|
542
|
+
if _is_noise_key(canonical_key):
|
|
543
|
+
continue
|
|
544
|
+
if canonical_key in self._extra_stopwords:
|
|
545
|
+
continue
|
|
546
|
+
if not _name_present_in_text(canonical_key, normalized_text):
|
|
547
|
+
continue
|
|
548
|
+
seen.setdefault((entity_type, canonical_key), display_name)
|
|
549
|
+
|
|
550
|
+
entities: list[ExtractedEntity] = []
|
|
551
|
+
for (entity_type, canonical_key), display_name in seen.items():
|
|
552
|
+
positions = _locate_positions(display_name, doc_words)
|
|
553
|
+
entities.append(
|
|
554
|
+
ExtractedEntity(
|
|
555
|
+
entity_type=entity_type,
|
|
556
|
+
canonical_key=canonical_key,
|
|
557
|
+
display_name=display_name,
|
|
558
|
+
positions=positions,
|
|
559
|
+
mention_count=max(1, len(positions)),
|
|
560
|
+
)
|
|
561
|
+
)
|
|
562
|
+
entities = _dedupe_project_substrings(entities)
|
|
563
|
+
entities = _dedupe_cross_type(entities)
|
|
564
|
+
entities = self._apply_cap(entities)
|
|
565
|
+
entities.sort(key=lambda entity: (entity.entity_type, entity.canonical_key))
|
|
566
|
+
return entities
|
|
567
|
+
|
|
568
|
+
def _apply_cap(self, entities: list[ExtractedEntity]) -> list[ExtractedEntity]:
|
|
569
|
+
"""Bound the per-document distinct-entity count (spec §10).
|
|
570
|
+
|
|
571
|
+
Keeps the most-mentioned entities (ties broken by ``canonical_key``
|
|
572
|
+
ascending for determinism), mirroring
|
|
573
|
+
:func:`brain.graph_rag.cooccur._apply_entity_cap`. ``None`` disables.
|
|
574
|
+
"""
|
|
575
|
+
if self._max_entities is None or len(entities) <= self._max_entities:
|
|
576
|
+
return entities
|
|
577
|
+
ranked = sorted(
|
|
578
|
+
entities,
|
|
579
|
+
key=lambda entity: (
|
|
580
|
+
-entity.mention_count,
|
|
581
|
+
entity.entity_type,
|
|
582
|
+
entity.canonical_key,
|
|
583
|
+
),
|
|
584
|
+
)
|
|
585
|
+
return ranked[: self._max_entities]
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def make_extractor(cfg: Config) -> OllamaExtractor:
|
|
589
|
+
"""Factory — same shape as :func:`brain.enrichment.make_enricher`.
|
|
590
|
+
|
|
591
|
+
Builds the :class:`OllamaExtractor` from ``cfg.graph_extract_model`` /
|
|
592
|
+
``cfg.ollama_host`` and the per-doc cap ``cfg.graph_max_entities``
|
|
593
|
+
(``BRAIN_GRAPH_MAX_ENTITIES_PER_DOC``). The model is the dedicated
|
|
594
|
+
``BRAIN_GRAPH_EXTRACT_MODEL`` so the concept extractor and the summary
|
|
595
|
+
enricher stay independently overridable (no enrich<->graph coupling).
|
|
596
|
+
|
|
597
|
+
The HTTP timeout is threaded from ``cfg.enrich_timeout_seconds``
|
|
598
|
+
(``BRAIN_ENRICH_TIMEOUT_SECONDS``) — NOT the 60s ``OllamaEnricher`` default.
|
|
599
|
+
A large document can take more than 60s to extract on a slow model; the old
|
|
600
|
+
hardcoded default timed those out and silently returned zero entities.
|
|
601
|
+
Operators raise ``BRAIN_ENRICH_TIMEOUT_SECONDS`` for slow models.
|
|
602
|
+
|
|
603
|
+
The input head cap is threaded from ``cfg.graph_extract_max_input_tokens``
|
|
604
|
+
(``BRAIN_GRAPH_EXTRACT_MAX_INPUT_TOKENS``, default 8000; ``None`` disables)
|
|
605
|
+
so a long document's body is truncated to its first N tokens before chunking
|
|
606
|
+
— bounding the per-document LLM call count (perf Fix C).
|
|
607
|
+
"""
|
|
608
|
+
enricher = OllamaEnricher(
|
|
609
|
+
host=cfg.ollama_host,
|
|
610
|
+
model=cfg.graph_extract_model,
|
|
611
|
+
timeout=cfg.enrich_timeout_seconds,
|
|
612
|
+
keep_alive=cfg.ollama_keep_alive,
|
|
613
|
+
)
|
|
614
|
+
return OllamaExtractor(
|
|
615
|
+
enricher=enricher,
|
|
616
|
+
max_entities=cfg.graph_max_entities,
|
|
617
|
+
max_input_tokens=cfg.graph_extract_max_input_tokens,
|
|
618
|
+
extra_stopwords=cfg.graph_extract_stopwords,
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
# --------------------------------------------------------------------------- #
|
|
623
|
+
# Pure helpers (validation / canonicalization / positions)
|
|
624
|
+
# --------------------------------------------------------------------------- #
|
|
625
|
+
def _validate_entry(entry: Any) -> tuple[str, str] | None:
|
|
626
|
+
"""Validate one raw model entry → ``(entity_type, display_name)`` or ``None``.
|
|
627
|
+
|
|
628
|
+
Deterministic, defensive validation (spec §16 "strict validation"): an entry
|
|
629
|
+
is accepted only when it is a ``{"name": str, "type": str}`` object with a
|
|
630
|
+
non-empty ``name`` and a ``type`` (case-insensitively) in
|
|
631
|
+
:data:`CONCEPT_ENTITY_TYPES`. Everything else — non-dicts, missing/extra
|
|
632
|
+
keys, wrong value types, empty names, people, unknown types — returns
|
|
633
|
+
``None`` and is skipped by the caller.
|
|
634
|
+
"""
|
|
635
|
+
if not isinstance(entry, dict):
|
|
636
|
+
return None
|
|
637
|
+
raw_name = entry.get("name")
|
|
638
|
+
raw_type = entry.get("type")
|
|
639
|
+
if not isinstance(raw_name, str) or not isinstance(raw_type, str):
|
|
640
|
+
return None
|
|
641
|
+
display_name = raw_name.strip()
|
|
642
|
+
entity_type = raw_type.strip().lower()
|
|
643
|
+
# Normalize near-synonym type labels (organization/software/initiative/theme/
|
|
644
|
+
# …) to the canonical type BEFORE the allowlist check, so a valid extraction
|
|
645
|
+
# is not dropped purely over label wording (a real recall loss). Keyed on the
|
|
646
|
+
# TYPE label only — never on the entity NAME.
|
|
647
|
+
entity_type = _TYPE_LABEL_SYNONYMS.get(entity_type, entity_type)
|
|
648
|
+
if not display_name:
|
|
649
|
+
return None
|
|
650
|
+
if entity_type not in CONCEPT_ENTITY_TYPES:
|
|
651
|
+
return None
|
|
652
|
+
return (entity_type, display_name)
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _canonical_key(display_name: str) -> str:
|
|
656
|
+
"""Canonical dedup key: lower-cased with ALL separators stripped (Bug B).
|
|
657
|
+
|
|
658
|
+
Lower-cases ``display_name`` and removes every non-alphanumeric run
|
|
659
|
+
(whitespace, hyphens, dots, underscores, special characters) via
|
|
660
|
+
:data:`_SEPARATOR_RE`, so the glued camelCase, hyphenated, spaced, and
|
|
661
|
+
concatenated surface forms of one concept (``AcmePlatform`` / ``acme-platform``
|
|
662
|
+
/ ``Acme Platform`` / ``acmeplatform``) all collapse to the SAME key
|
|
663
|
+
(``acmeplatform``) and merge into one ``graph_entities`` row instead of
|
|
664
|
+
fragmenting. This is *strip-all*, not collapse-to-space — the weaker
|
|
665
|
+
whitespace-only normalization left separator variants as distinct keys.
|
|
666
|
+
|
|
667
|
+
Backs the catalog's ``UNIQUE(tenant_id, entity_type, canonical_key)`` (spec
|
|
668
|
+
§5). Returns an empty string only for a name with no alphanumeric content
|
|
669
|
+
(caller skips it). The presence check (:func:`_normalize_for_presence`) strips
|
|
670
|
+
separators the same way, so the two stay consistent.
|
|
671
|
+
"""
|
|
672
|
+
return _SEPARATOR_RE.sub("", display_name.lower())
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _tokenize_words(text: str) -> list[str]:
|
|
676
|
+
r"""Lower-cased ``\w+`` word tokens — the raw-text position unit (spec §4 D4)."""
|
|
677
|
+
return _WORD_RE.findall(text.lower())
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def _locate_positions(name: str, doc_words: list[str]) -> tuple[int, ...]:
|
|
681
|
+
"""Word-index start positions of ``name`` in ``doc_words`` (case-insensitive).
|
|
682
|
+
|
|
683
|
+
Finds every occurrence of the (possibly multi-word) entity name as a
|
|
684
|
+
contiguous sub-sequence of the document's word stream and returns each
|
|
685
|
+
match's starting word index. Empty when the name's words never appear
|
|
686
|
+
contiguously (e.g. the model paraphrased a concept not present verbatim).
|
|
687
|
+
"""
|
|
688
|
+
name_words = _tokenize_words(name)
|
|
689
|
+
if not name_words:
|
|
690
|
+
return ()
|
|
691
|
+
span = len(name_words)
|
|
692
|
+
last_start = len(doc_words) - span
|
|
693
|
+
return tuple(
|
|
694
|
+
i for i in range(last_start + 1) if doc_words[i : i + span] == name_words
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _is_noise_key(canonical_key: str) -> bool:
|
|
699
|
+
"""True when a canonical key is too short, a generic stop word, or structural.
|
|
700
|
+
|
|
701
|
+
Drops single-character noise (``len < _MIN_CANONICAL_KEY_LEN``), an exact
|
|
702
|
+
match against the generic :data:`_GENERIC_STOP_KEYS` / file-format +
|
|
703
|
+
document-structure :data:`_STRUCTURAL_STOP_KEYS` lists, and the
|
|
704
|
+
"<structural noun> <enumerator>" pattern (``chapter 24``, ``section 3``,
|
|
705
|
+
``standard 72`` — :data:`_STRUCTURAL_ENUM_RE`). All checks are deliberately
|
|
706
|
+
narrow + generic to protect recall — short acronyms (>= 2 chars) and every
|
|
707
|
+
real domain concept pass through.
|
|
708
|
+
"""
|
|
709
|
+
return (
|
|
710
|
+
len(canonical_key) < _MIN_CANONICAL_KEY_LEN
|
|
711
|
+
or canonical_key in _GENERIC_STOP_KEYS
|
|
712
|
+
or canonical_key in _STRUCTURAL_STOP_KEYS
|
|
713
|
+
or _STRUCTURAL_ENUM_RE.match(canonical_key) is not None
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _normalize_for_presence(text: str) -> str:
|
|
718
|
+
"""Lower-case ``text`` and strip every non-alphanumeric run (Bug B).
|
|
719
|
+
|
|
720
|
+
The shared normalization for the presence check: separators (whitespace,
|
|
721
|
+
hyphens, dots, underscores, special characters) are REMOVED entirely — the
|
|
722
|
+
same strip-all rule as :func:`_canonical_key`. Stripping on both sides keeps
|
|
723
|
+
presence matching consistent with the strip-all key regardless of which side
|
|
724
|
+
carries the separators: a glued canonical key (``acmeplatform``) matches a
|
|
725
|
+
separated source form (``Acme Platform``), and a separated surface form
|
|
726
|
+
matches a glued source form (``AcmePlatform``). Without this, the strip-all
|
|
727
|
+
key would never substring-match a strip-to-space document and every
|
|
728
|
+
separator-bearing entity would be falsely dropped.
|
|
729
|
+
|
|
730
|
+
Trade-off: removing separators makes the substring match deliberately MORE
|
|
731
|
+
permissive across word boundaries (e.g. ``"acme"`` matches ``"...acme..."``
|
|
732
|
+
even mid-word), so very short entity names (<= 4 chars) carry a higher
|
|
733
|
+
false-positive risk. Accepted: the presence check is intentionally lenient
|
|
734
|
+
(recall-safe) — its only job is to drop names that do not occur at all, and
|
|
735
|
+
the strip-all key consistency matters more than tightening this edge.
|
|
736
|
+
"""
|
|
737
|
+
return _SEPARATOR_RE.sub("", text.lower())
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def _name_present_in_text(canonical_key: str, normalized_text: str) -> bool:
|
|
741
|
+
"""True when the entity name actually appears in the source text (B.1).
|
|
742
|
+
|
|
743
|
+
Strip-all substring match: the canonical key is re-normalized the same way as
|
|
744
|
+
``normalized_text`` (:func:`_normalize_for_presence` — all separators removed)
|
|
745
|
+
and checked for containment. Because the key is already strip-all, the needle
|
|
746
|
+
equals the key; matching against strip-all text means a glued key
|
|
747
|
+
(``acmeplatform``) is found in a separated source (``Acme Platform`` →
|
|
748
|
+
``acmeplatform``) and vice versa. Deliberately a substring (not a whole-word)
|
|
749
|
+
match so a bare ``"helios"`` still counts as present when the document writes
|
|
750
|
+
``"Helioscope"`` (lenient = recall-safe); the goal is only to drop names that
|
|
751
|
+
do **not** occur at all — hallucinated few-shot example names and paraphrases
|
|
752
|
+
the model invented but never wrote.
|
|
753
|
+
"""
|
|
754
|
+
needle = _normalize_for_presence(canonical_key)
|
|
755
|
+
if not needle:
|
|
756
|
+
return False
|
|
757
|
+
return needle in normalized_text
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def _is_reasoning_text(display_name: str) -> bool:
|
|
761
|
+
"""True when a candidate "name" is the model's reasoning text, not an entity.
|
|
762
|
+
|
|
763
|
+
Drops sentence-length names (more than :data:`_MAX_ENTITY_WORDS` words) and
|
|
764
|
+
names containing meta-commentary markers (:data:`_REASONING_PATTERNS`, e.g.
|
|
765
|
+
"is not present", "however", "the document discusses") that a small model
|
|
766
|
+
emits as an "entity" on entity-sparse documents (audit B.2). Generic phrasing
|
|
767
|
+
only — never keyed on a specific entity name.
|
|
768
|
+
"""
|
|
769
|
+
stripped = display_name.strip()
|
|
770
|
+
if not stripped:
|
|
771
|
+
return True
|
|
772
|
+
if len(stripped.split()) > _MAX_ENTITY_WORDS:
|
|
773
|
+
return True
|
|
774
|
+
return _REASONING_PATTERNS.search(stripped) is not None
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def _dedupe_cross_type(entities: list[ExtractedEntity]) -> list[ExtractedEntity]:
|
|
778
|
+
"""Collapse one canonical name emitted under multiple types to a single node.
|
|
779
|
+
|
|
780
|
+
Audit B.3/B.4: the model frequently emits the same real entity under more
|
|
781
|
+
than one type (a standards body as both ``org`` and ``topic``; each numbered
|
|
782
|
+
standard duplicated across ``topic`` / ``org`` / ``tool``), inflating and
|
|
783
|
+
fragmenting the graph and the Louvain communities built from it. For each
|
|
784
|
+
canonical key, keep the single highest-precedence type
|
|
785
|
+
(:data:`_TYPE_PRECEDENCE`) and drop the lower-precedence duplicates. Order is
|
|
786
|
+
preserved. Positions are a pure function of the name + document text, so the
|
|
787
|
+
surviving node already carries the correct ``mention_count`` — no re-summing.
|
|
788
|
+
"""
|
|
789
|
+
unknown_rank = len(_TYPE_PRECEDENCE)
|
|
790
|
+
best_rank: dict[str, int] = {}
|
|
791
|
+
for entity in entities:
|
|
792
|
+
rank = _TYPE_RANK.get(entity.entity_type, unknown_rank)
|
|
793
|
+
current = best_rank.get(entity.canonical_key)
|
|
794
|
+
if current is None or rank < current:
|
|
795
|
+
best_rank[entity.canonical_key] = rank
|
|
796
|
+
return [
|
|
797
|
+
entity
|
|
798
|
+
for entity in entities
|
|
799
|
+
if _TYPE_RANK.get(entity.entity_type, unknown_rank)
|
|
800
|
+
== best_rank[entity.canonical_key]
|
|
801
|
+
]
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def _repair_project_prefix(entity_type: str, display_name: str, text: str) -> str:
|
|
805
|
+
"""Restore a stripped ``Project`` prefix when the document names it that way.
|
|
806
|
+
|
|
807
|
+
Small models often return the bare project name (``"Helios"``) for a concept
|
|
808
|
+
the document writes as ``"Project Helios"``. When the entity is a ``project``
|
|
809
|
+
and the ``"Project <name>"`` form appears verbatim in the text, prefer that
|
|
810
|
+
fuller surface form so the canonical key matches how the project is actually
|
|
811
|
+
named. Generic (any project name) — never keyed on a specific entity.
|
|
812
|
+
|
|
813
|
+
The match is a contiguous WORD/token sequence, not a substring: a bare
|
|
814
|
+
``"Helios"`` is NOT promoted when the document only says ``"Project
|
|
815
|
+
Helioscope"`` (which contains ``"project helios"`` as a substring).
|
|
816
|
+
"""
|
|
817
|
+
if entity_type != "project":
|
|
818
|
+
return display_name
|
|
819
|
+
stripped = display_name.strip()
|
|
820
|
+
if not stripped or stripped.lower().startswith("project "):
|
|
821
|
+
return display_name
|
|
822
|
+
name_words = _tokenize_words(stripped)
|
|
823
|
+
if not name_words:
|
|
824
|
+
return display_name
|
|
825
|
+
target = ["project", *name_words]
|
|
826
|
+
doc_words = _tokenize_words(text)
|
|
827
|
+
span = len(target)
|
|
828
|
+
if any(
|
|
829
|
+
doc_words[i : i + span] == target
|
|
830
|
+
for i in range(len(doc_words) - span + 1)
|
|
831
|
+
):
|
|
832
|
+
return f"Project {stripped}"
|
|
833
|
+
return display_name
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def _is_word_subsequence(short_words: list[str], long_words: list[str]) -> bool:
|
|
837
|
+
"""True when ``short_words`` appears as a contiguous run inside ``long_words``."""
|
|
838
|
+
span = len(short_words)
|
|
839
|
+
if not span or span >= len(long_words):
|
|
840
|
+
return False
|
|
841
|
+
return any(
|
|
842
|
+
long_words[i : i + span] == short_words
|
|
843
|
+
for i in range(len(long_words) - span + 1)
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _dedupe_project_substrings(
|
|
848
|
+
entities: list[ExtractedEntity],
|
|
849
|
+
) -> list[ExtractedEntity]:
|
|
850
|
+
"""Drop a bare ``project`` entity subsumed by a fuller ``"Project X"`` sibling.
|
|
851
|
+
|
|
852
|
+
Conservative and PROJECT-SCOPED: when two ``project`` entities exist and one's
|
|
853
|
+
name is a contiguous word-subsequence of the other (e.g. ``Helios`` vs
|
|
854
|
+
``Project Helios``), keep only the longer named form. Deliberately NOT applied
|
|
855
|
+
to org/tool/topic — for those a shorter key is often the correct concept
|
|
856
|
+
(``billing`` vs ``billing platform``), so a blanket keep-longest would hurt
|
|
857
|
+
recall.
|
|
858
|
+
|
|
859
|
+
The word-subsequence test runs on tokens of the ``display_name`` (via
|
|
860
|
+
:func:`_tokenize_words`), NOT on ``canonical_key`` — the strip-all key (Bug B)
|
|
861
|
+
has every separator removed, so ``canonical_key.split()`` no longer yields the
|
|
862
|
+
individual words needed for a subsequence check. The drop set is still keyed
|
|
863
|
+
by ``canonical_key`` (the entity identity).
|
|
864
|
+
"""
|
|
865
|
+
projects = [e for e in entities if e.entity_type == "project"]
|
|
866
|
+
if len(projects) < 2:
|
|
867
|
+
return entities
|
|
868
|
+
drop_keys: set[str] = set()
|
|
869
|
+
for candidate in projects:
|
|
870
|
+
candidate_words = _tokenize_words(candidate.display_name)
|
|
871
|
+
for other in projects:
|
|
872
|
+
if other.canonical_key == candidate.canonical_key:
|
|
873
|
+
continue
|
|
874
|
+
if _is_word_subsequence(
|
|
875
|
+
candidate_words, _tokenize_words(other.display_name)
|
|
876
|
+
):
|
|
877
|
+
drop_keys.add(candidate.canonical_key)
|
|
878
|
+
break
|
|
879
|
+
if not drop_keys:
|
|
880
|
+
return entities
|
|
881
|
+
return [
|
|
882
|
+
e
|
|
883
|
+
for e in entities
|
|
884
|
+
if not (e.entity_type == "project" and e.canonical_key in drop_keys)
|
|
885
|
+
]
|