open-pharma-plugins 2.2.0__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.
- mcp_framework.py +495 -0
- open_pharma_plugins-2.2.0.dist-info/METADATA +135 -0
- open_pharma_plugins-2.2.0.dist-info/RECORD +136 -0
- open_pharma_plugins-2.2.0.dist-info/WHEEL +5 -0
- open_pharma_plugins-2.2.0.dist-info/entry_points.txt +8 -0
- open_pharma_plugins-2.2.0.dist-info/licenses/LICENSE +202 -0
- open_pharma_plugins-2.2.0.dist-info/top_level.txt +8 -0
- open_pharma_plugins_campaign_studio/__init__.py +14 -0
- open_pharma_plugins_campaign_studio/__main__.py +11 -0
- open_pharma_plugins_campaign_studio/_campaign_store.py +162 -0
- open_pharma_plugins_campaign_studio/_claim_engine.py +262 -0
- open_pharma_plugins_campaign_studio/_renderer.py +119 -0
- open_pharma_plugins_campaign_studio/fixtures/brand_kit/legal.json +21 -0
- open_pharma_plugins_campaign_studio/fixtures/brand_kit/logo.svg +4 -0
- open_pharma_plugins_campaign_studio/fixtures/brand_kit/palette.json +11 -0
- open_pharma_plugins_campaign_studio/fixtures/brand_kit/product.png +1 -0
- open_pharma_plugins_campaign_studio/fixtures/brand_kit/typography.json +14 -0
- open_pharma_plugins_campaign_studio/fixtures/sample_approved_claims.json +119 -0
- open_pharma_plugins_campaign_studio/models/__init__.py +34 -0
- open_pharma_plugins_campaign_studio/models/_common.py +12 -0
- open_pharma_plugins_campaign_studio/models/brief.py +72 -0
- open_pharma_plugins_campaign_studio/models/claims.py +15 -0
- open_pharma_plugins_campaign_studio/models/copy.py +47 -0
- open_pharma_plugins_campaign_studio/models/journey.py +21 -0
- open_pharma_plugins_campaign_studio/models/message.py +25 -0
- open_pharma_plugins_campaign_studio/models/mlr.py +29 -0
- open_pharma_plugins_campaign_studio/models/validation.py +32 -0
- open_pharma_plugins_campaign_studio/policy/rules.json +79 -0
- open_pharma_plugins_campaign_studio/templates/banner.svg.j2 +26 -0
- open_pharma_plugins_campaign_studio/templates/email.html.j2 +54 -0
- open_pharma_plugins_campaign_studio/tools/__init__.py +0 -0
- open_pharma_plugins_campaign_studio/tools/create_campaign_brief.py +212 -0
- open_pharma_plugins_campaign_studio/tools/generate_audience_journey.py +129 -0
- open_pharma_plugins_campaign_studio/tools/generate_channel_copy.py +199 -0
- open_pharma_plugins_campaign_studio/tools/generate_message_architecture.py +121 -0
- open_pharma_plugins_campaign_studio/tools/package_mlr_submission.py +230 -0
- open_pharma_plugins_campaign_studio/tools/render_banner.py +99 -0
- open_pharma_plugins_campaign_studio/tools/render_email.py +101 -0
- open_pharma_plugins_campaign_studio/tools/render_poster.py +222 -0
- open_pharma_plugins_campaign_studio/tools/retrieve_approved_claims.py +71 -0
- open_pharma_plugins_campaign_studio/tools/retrieve_brand_components.py +76 -0
- open_pharma_plugins_campaign_studio/tools/validate_claims_and_fair_balance.py +285 -0
- open_pharma_plugins_competitive_intelligence/__init__.py +13 -0
- open_pharma_plugins_competitive_intelligence/__main__.py +11 -0
- open_pharma_plugins_competitive_intelligence/_artifacts.py +87 -0
- open_pharma_plugins_competitive_intelligence/_cache.py +144 -0
- open_pharma_plugins_competitive_intelligence/_clinical_trials.py +569 -0
- open_pharma_plugins_competitive_intelligence/_dailymed.py +260 -0
- open_pharma_plugins_competitive_intelligence/_fda.py +255 -0
- open_pharma_plugins_competitive_intelligence/_pubmed.py +342 -0
- open_pharma_plugins_competitive_intelligence/_regulatory.py +140 -0
- open_pharma_plugins_competitive_intelligence/_runs.py +278 -0
- open_pharma_plugins_competitive_intelligence/_transport.py +83 -0
- open_pharma_plugins_competitive_intelligence/_watchlist.py +113 -0
- open_pharma_plugins_competitive_intelligence/_web_search.py +331 -0
- open_pharma_plugins_competitive_intelligence/models.py +525 -0
- open_pharma_plugins_competitive_intelligence/tools/__init__.py +0 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_extract_events.py +247 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_landscape.py +195 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_refresh.py +101 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_report.py +402 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_scan_news.py +48 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_scan_publications.py +46 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_scan_regulatory.py +64 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_scan_trials.py +85 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_status.py +106 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_timeline.py +453 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_track.py +121 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_trial_detail.py +55 -0
- open_pharma_plugins_field_training/__init__.py +13 -0
- open_pharma_plugins_field_training/__main__.py +11 -0
- open_pharma_plugins_field_training/_content_store.py +131 -0
- open_pharma_plugins_field_training/_grounding.py +75 -0
- open_pharma_plugins_field_training/_html_renderers.py +546 -0
- open_pharma_plugins_field_training/fixtures/sample_product_message.pdf +156 -0
- open_pharma_plugins_field_training/fixtures/sample_training_deck.pptx +0 -0
- open_pharma_plugins_field_training/models.py +265 -0
- open_pharma_plugins_field_training/tools/__init__.py +0 -0
- open_pharma_plugins_field_training/tools/get_document_page.py +67 -0
- open_pharma_plugins_field_training/tools/ingest_document.py +147 -0
- open_pharma_plugins_field_training/tools/list_documents.py +51 -0
- open_pharma_plugins_field_training/tools/render_output.py +118 -0
- open_pharma_plugins_field_training/tools/search_content.py +57 -0
- open_pharma_plugins_hcp_intelligence/__init__.py +14 -0
- open_pharma_plugins_hcp_intelligence/__main__.py +11 -0
- open_pharma_plugins_hcp_intelligence/_crm_store.py +70 -0
- open_pharma_plugins_hcp_intelligence/batch.py +891 -0
- open_pharma_plugins_hcp_intelligence/batch_cli.py +221 -0
- open_pharma_plugins_hcp_intelligence/batch_csv.py +206 -0
- open_pharma_plugins_hcp_intelligence/fixtures/sample_accounts.csv +27 -0
- open_pharma_plugins_hcp_intelligence/models.py +356 -0
- open_pharma_plugins_hcp_intelligence/tools/__init__.py +0 -0
- open_pharma_plugins_hcp_intelligence/tools/get_account.py +48 -0
- open_pharma_plugins_hcp_intelligence/tools/list_accounts.py +66 -0
- open_pharma_plugins_hcp_intelligence/tools/search_clinical_trials.py +175 -0
- open_pharma_plugins_hcp_intelligence/tools/search_congresses.py +134 -0
- open_pharma_plugins_hcp_intelligence/tools/search_grants.py +181 -0
- open_pharma_plugins_hcp_intelligence/tools/search_guidelines.py +238 -0
- open_pharma_plugins_hcp_intelligence/tools/search_hco_web.py +73 -0
- open_pharma_plugins_hcp_intelligence/tools/search_hcp_web.py +199 -0
- open_pharma_plugins_hcp_intelligence/tools/search_orcid.py +214 -0
- open_pharma_plugins_hcp_intelligence/tools/search_publications.py +207 -0
- open_pharma_plugins_hcp_intelligence/tools/update_account.py +84 -0
- open_pharma_plugins_next_best_engagement/__init__.py +14 -0
- open_pharma_plugins_next_best_engagement/__main__.py +11 -0
- open_pharma_plugins_next_best_engagement/_optimizer.py +400 -0
- open_pharma_plugins_next_best_engagement/_renderer.py +149 -0
- open_pharma_plugins_next_best_engagement/_scoring.py +82 -0
- open_pharma_plugins_next_best_engagement/_universe.py +145 -0
- open_pharma_plugins_next_best_engagement/fixtures/sample_universe.csv +81 -0
- open_pharma_plugins_next_best_engagement/models.py +135 -0
- open_pharma_plugins_next_best_engagement/tools/__init__.py +0 -0
- open_pharma_plugins_next_best_engagement/tools/load_universe.py +47 -0
- open_pharma_plugins_next_best_engagement/tools/recommend_engagements.py +80 -0
- open_pharma_plugins_next_best_engagement/tools/render_plan.py +90 -0
- open_pharma_plugins_territory_alignment/__init__.py +14 -0
- open_pharma_plugins_territory_alignment/__main__.py +11 -0
- open_pharma_plugins_territory_alignment/data.py +300 -0
- open_pharma_plugins_territory_alignment/fixtures/constraints.csv +11 -0
- open_pharma_plugins_territory_alignment/fixtures/current_alignment.csv +81 -0
- open_pharma_plugins_territory_alignment/fixtures/hcps.csv +81 -0
- open_pharma_plugins_territory_alignment/fixtures/reps.csv +9 -0
- open_pharma_plugins_territory_alignment/geo.py +175 -0
- open_pharma_plugins_territory_alignment/models.py +201 -0
- open_pharma_plugins_territory_alignment/scoring.py +125 -0
- open_pharma_plugins_territory_alignment/solver.py +504 -0
- open_pharma_plugins_territory_alignment/tools/__init__.py +0 -0
- open_pharma_plugins_territory_alignment/tools/ta_align.py +138 -0
- open_pharma_plugins_territory_alignment/tools/ta_cluster.py +247 -0
- open_pharma_plugins_territory_alignment/tools/ta_compare.py +173 -0
- open_pharma_plugins_territory_alignment/tools/ta_evaluate.py +119 -0
- open_pharma_plugins_territory_alignment/tools/ta_status.py +34 -0
- open_pharma_plugins_territory_alignment/tools/ta_visualize.py +504 -0
- shared/__init__.py +11 -0
- shared/env.py +217 -0
- shared/filesystem.py +110 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""ci_trial_detail — full trial record with arms, endpoints, and results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
from .. import _clinical_trials
|
|
11
|
+
from ..models import TrialDetailRequest
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TrialDetailArgs(BaseModel):
|
|
15
|
+
nct_id: str = Field(description="ClinicalTrials.gov identifier, e.g. 'NCT04567890'")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
TOOL: dict[str, Any] = {
|
|
19
|
+
"name": "ci_trial_detail",
|
|
20
|
+
"description": (
|
|
21
|
+
"Fetch the full record for a single clinical trial including arms, "
|
|
22
|
+
"endpoints, eligibility criteria, current status, milestone dates, and "
|
|
23
|
+
"posted results. Use after ci_scan_trials to drill into a specific trial."
|
|
24
|
+
),
|
|
25
|
+
"args": TrialDetailArgs,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def handle(arguments: dict[str, Any]) -> list[dict[str, Any]]:
|
|
30
|
+
request = TrialDetailRequest.model_validate(arguments)
|
|
31
|
+
result = _clinical_trials.get_trial_detail(request)
|
|
32
|
+
ledger = result.model_dump(mode="json", exclude={"records"})
|
|
33
|
+
if not result.records:
|
|
34
|
+
payload: dict[str, Any] = {
|
|
35
|
+
"error": result.error.message if result.error else "ClinicalTrials.gov detail failed",
|
|
36
|
+
"coverage": result.status.value,
|
|
37
|
+
"source_ledger": [ledger],
|
|
38
|
+
"limitations": result.limitations,
|
|
39
|
+
}
|
|
40
|
+
return [{"type": "text", "text": json.dumps(payload, indent=2)}]
|
|
41
|
+
|
|
42
|
+
detail = dict(result.records[0])
|
|
43
|
+
trial = dict(detail["trial"])
|
|
44
|
+
interventions = trial.get("interventions", [])
|
|
45
|
+
trial["intervention_details"] = interventions
|
|
46
|
+
trial["interventions"] = [item.get("name", "") for item in interventions]
|
|
47
|
+
detail["trial"] = trial
|
|
48
|
+
detail.update(
|
|
49
|
+
{
|
|
50
|
+
"coverage": result.status.value,
|
|
51
|
+
"source_ledger": [ledger],
|
|
52
|
+
"limitations": result.limitations,
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
return [{"type": "text", "text": json.dumps(detail, indent=2)}]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from mcp_framework import build_registry
|
|
2
|
+
|
|
3
|
+
__version__ = "1.1.1"
|
|
4
|
+
|
|
5
|
+
SPECS, get_handler, list_tools = build_registry(__name__, ["tools"])
|
|
6
|
+
|
|
7
|
+
USAGE_NOTE = (
|
|
8
|
+
"Field Training turns approved PDF/PPTX file paths into source-grounded "
|
|
9
|
+
"learning packages, assessments, pre-session role-play kits, and "
|
|
10
|
+
"post-session scorecards for field reps."
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
SYSTEM_DEPS = []
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Content store for ingested training documents.
|
|
2
|
+
|
|
3
|
+
Stores extracted document content as JSON files in a configurable directory.
|
|
4
|
+
A manifest (_index.json) tracks all ingested documents.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from shared.filesystem import atomic_write_json, contained_path, ensure_private_dir
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _store_dir() -> Path:
|
|
18
|
+
from shared.env import get_env
|
|
19
|
+
|
|
20
|
+
d = Path(
|
|
21
|
+
get_env(
|
|
22
|
+
"OPEN_PHARMA_TRAINING_CONTENT_DIR",
|
|
23
|
+
str(Path.home() / ".open-pharma-plugins" / "training-content"),
|
|
24
|
+
)
|
|
25
|
+
)
|
|
26
|
+
return ensure_private_dir(d)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def document_id_from_path(file_path: str) -> str:
|
|
30
|
+
"""Generate a stable document ID from the file path."""
|
|
31
|
+
name = Path(file_path).stem
|
|
32
|
+
short_hash = hashlib.sha256(file_path.encode()).hexdigest()[:8]
|
|
33
|
+
safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in name)[:40]
|
|
34
|
+
return f"{safe_name}_{short_hash}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def save_document(doc: dict) -> None:
|
|
38
|
+
"""Save an ingested document to the content store."""
|
|
39
|
+
store = _store_dir()
|
|
40
|
+
doc_path = contained_path(store, f"{doc['document_id']}.json")
|
|
41
|
+
atomic_write_json(doc_path, doc)
|
|
42
|
+
_update_index(doc)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load_document(document_id: str) -> dict | None:
|
|
46
|
+
"""Load a document by ID."""
|
|
47
|
+
doc_path = contained_path(_store_dir(), f"{document_id}.json")
|
|
48
|
+
if doc_path.exists():
|
|
49
|
+
return json.loads(doc_path.read_text())
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def list_documents(file_type: str | None = None) -> list[dict]:
|
|
54
|
+
"""List all ingested documents from the index."""
|
|
55
|
+
index = _load_index()
|
|
56
|
+
docs = index.get("documents", [])
|
|
57
|
+
if file_type:
|
|
58
|
+
docs = [d for d in docs if d.get("file_type") == file_type.lower()]
|
|
59
|
+
return docs
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def search_pages(query: str, document_id: str | None = None, max_results: int = 10) -> list[dict]:
|
|
63
|
+
"""Simple keyword search across all ingested documents.
|
|
64
|
+
|
|
65
|
+
Returns ranked results with document info and page content.
|
|
66
|
+
Uses term frequency scoring.
|
|
67
|
+
"""
|
|
68
|
+
query_terms = set(query.lower().split())
|
|
69
|
+
if not query_terms:
|
|
70
|
+
return []
|
|
71
|
+
|
|
72
|
+
results = []
|
|
73
|
+
|
|
74
|
+
doc_ids = [document_id] if document_id else [d["document_id"] for d in list_documents()]
|
|
75
|
+
|
|
76
|
+
for doc_id in doc_ids:
|
|
77
|
+
doc = load_document(doc_id)
|
|
78
|
+
if doc is None:
|
|
79
|
+
continue
|
|
80
|
+
for page in doc.get("pages", []):
|
|
81
|
+
text = page.get("text", "")
|
|
82
|
+
text_lower = text.lower()
|
|
83
|
+
|
|
84
|
+
notes = page.get("speaker_notes") or ""
|
|
85
|
+
combined = text_lower + " " + notes.lower()
|
|
86
|
+
|
|
87
|
+
score = sum(combined.count(term) for term in query_terms)
|
|
88
|
+
if score > 0:
|
|
89
|
+
if query.lower() in combined:
|
|
90
|
+
score += 10
|
|
91
|
+
results.append(
|
|
92
|
+
{
|
|
93
|
+
"document_id": doc_id,
|
|
94
|
+
"document_name": doc.get("file_name", ""),
|
|
95
|
+
"page_number": page["page_number"],
|
|
96
|
+
"page_type": page.get("page_type", "page"),
|
|
97
|
+
"slide_title": page.get("slide_title"),
|
|
98
|
+
"text": text[:500],
|
|
99
|
+
"speaker_notes": notes[:300] if notes else None,
|
|
100
|
+
"score": score,
|
|
101
|
+
}
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
results.sort(key=lambda r: r["score"], reverse=True)
|
|
105
|
+
return results[:max_results]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _load_index() -> dict:
|
|
109
|
+
index_path = contained_path(_store_dir(), "_index.json")
|
|
110
|
+
if index_path.exists():
|
|
111
|
+
return json.loads(index_path.read_text())
|
|
112
|
+
return {"documents": []}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _update_index(doc: dict) -> None:
|
|
116
|
+
index = _load_index()
|
|
117
|
+
docs = index.get("documents", [])
|
|
118
|
+
docs = [d for d in docs if d.get("document_id") != doc["document_id"]]
|
|
119
|
+
docs.append(
|
|
120
|
+
{
|
|
121
|
+
"document_id": doc["document_id"],
|
|
122
|
+
"file_name": doc.get("file_name", ""),
|
|
123
|
+
"file_type": doc.get("file_type", ""),
|
|
124
|
+
"title": doc.get("title"),
|
|
125
|
+
"total_pages": doc.get("total_pages", 0),
|
|
126
|
+
"ingested_at": doc.get("ingested_at", datetime.now(timezone.utc).isoformat()),
|
|
127
|
+
}
|
|
128
|
+
)
|
|
129
|
+
index["documents"] = docs
|
|
130
|
+
index_path = contained_path(_store_dir(), "_index.json")
|
|
131
|
+
atomic_write_json(index_path, index)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Validate training-output citations against the ingested content store."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
from ._content_store import list_documents, load_document
|
|
10
|
+
from .models import SourceReference
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def validate_output_sources(output: BaseModel) -> list[str]:
|
|
14
|
+
errors: list[str] = []
|
|
15
|
+
declared_names = set(getattr(output, "source_documents", []))
|
|
16
|
+
known_documents = {item.get("file_name", ""): item for item in list_documents()}
|
|
17
|
+
|
|
18
|
+
for name in sorted(declared_names):
|
|
19
|
+
if name not in known_documents:
|
|
20
|
+
errors.append(f"source_documents contains unknown ingested document {name!r}")
|
|
21
|
+
|
|
22
|
+
for reference in _source_references(output):
|
|
23
|
+
if reference.document_name not in declared_names:
|
|
24
|
+
errors.append(
|
|
25
|
+
f"source {reference.document_id!r} names {reference.document_name!r} "
|
|
26
|
+
"but that file is absent from source_documents"
|
|
27
|
+
)
|
|
28
|
+
try:
|
|
29
|
+
document = load_document(reference.document_id)
|
|
30
|
+
except ValueError as exc:
|
|
31
|
+
errors.append(str(exc))
|
|
32
|
+
continue
|
|
33
|
+
if document is None:
|
|
34
|
+
errors.append(f"source document_id {reference.document_id!r} is not ingested")
|
|
35
|
+
continue
|
|
36
|
+
if document.get("file_name") != reference.document_name:
|
|
37
|
+
errors.append(
|
|
38
|
+
f"source {reference.document_id!r} document_name does not match ingested file "
|
|
39
|
+
f"{document.get('file_name')!r}"
|
|
40
|
+
)
|
|
41
|
+
page = next(
|
|
42
|
+
(item for item in document.get("pages", []) if item.get("page_number") == reference.page_number),
|
|
43
|
+
None,
|
|
44
|
+
)
|
|
45
|
+
if page is None:
|
|
46
|
+
errors.append(f"source {reference.document_id!r} has no page or slide {reference.page_number}")
|
|
47
|
+
continue
|
|
48
|
+
excerpt = _normalize(reference.excerpt)
|
|
49
|
+
source_text = _normalize(f"{page.get('text', '')} {page.get('speaker_notes') or ''}")
|
|
50
|
+
if not excerpt or excerpt not in source_text:
|
|
51
|
+
errors.append(
|
|
52
|
+
f"source {reference.document_id!r} page {reference.page_number} excerpt is not present in ingested content"
|
|
53
|
+
)
|
|
54
|
+
return errors
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _source_references(value: object) -> Iterator[SourceReference]:
|
|
58
|
+
if isinstance(value, SourceReference):
|
|
59
|
+
yield value
|
|
60
|
+
return
|
|
61
|
+
if isinstance(value, BaseModel):
|
|
62
|
+
for field in type(value).model_fields:
|
|
63
|
+
yield from _source_references(getattr(value, field))
|
|
64
|
+
return
|
|
65
|
+
if isinstance(value, dict):
|
|
66
|
+
for item in value.values():
|
|
67
|
+
yield from _source_references(item)
|
|
68
|
+
return
|
|
69
|
+
if isinstance(value, (list, tuple, set)):
|
|
70
|
+
for item in value:
|
|
71
|
+
yield from _source_references(item)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _normalize(text: str) -> str:
|
|
75
|
+
return " ".join(str(text).split()).casefold()
|