Python API reference
Key classes and functions in researcher_profiles. The core install
(pydantic + pyyaml) provides the schema models, ResearcherProfile, and the
OpenAlex parser. Other surfaces attach or import only when their extra is present.
For the narrative behind the module layout, the storage interface, the capability
managers, and the export idempotency contract, see the developer notes in
docs-dev/rp-sdk/explanation/sdk-architecture.md.
Capability by extra
Section titled “Capability by extra”| Surface | Import | Extra |
|---|---|---|
Schema models, ResearcherProfile, OpenAlex parser | researcher_profiles | core |
resolve_person | researcher_profiles.resolve | core |
prof.index (SqliteEmbeddingIndex), store.match / .centroids / .indexes | researcher_profiles.embeddings, researcher_profiles.analytics | [vectors,st] |
prof.persona (ask / review / innovate / riff / chat) | researcher_profiles.generative | [llm] |
ApiArtifactStorage, StaticArtifactStorage, .from_api(), .from_url(), rank_against(), resolve_rid() | researcher_profiles.client | [client] |
ProfileStore (protocol), FilesystemProfileStore | researcher_profiles.store | core |
create_app | researcher_profiles.api.app | [api] |
ArtifactStorage (ABC), DirectoryArtifactStorage | researcher_profiles.profile.storage | core |
SqlProfileStore, SqlArtifactStorage, .from_db(), the rp_* tables | researcher_profiles.store.db, researcher_profiles.store.sql | [sql] |
Each capability manager is built lazily on first access, so a core-only install
still imports the package and a missing extra raises an ImportError naming it
at first use. The optional public names (LLMClient, ApiArtifactStorage,
SqlProfileStore, …) do the same when accessed.
ResearcherProfile
Section titled “ResearcherProfile”researcher_profiles.ResearcherProfile is a profile loaded from a directory.
Persistence goes through a composed ArtifactStorage, and capability managers
(prof.persona, prof.index, prof.cite, prof.coverage, prof.topics,
prof.edit) hang off the aggregate. Both are described in
docs-dev/rp-sdk/explanation/sdk-architecture.md.
ResearcherProfile
Section titled “ResearcherProfile”class ResearcherProfile(storage: ArtifactStorage, *, eager: bool = False)A researcher profile loaded from a directory on disk.
Use from_files as the documented entry point; the bare
constructor is the same code path with no validation.
Properties
Section titled “Properties”affiliation
Section titled “affiliation”@propertydef affiliation -> str | Nonebuild_state
Section titled “build_state”@propertydef build_state -> BuildStateBuild bookkeeping from .build/<slug>/meta/build_state.json.
Not part of the published record. Empty when the sidecar is absent.
citations
Section titled “citations”@propertydef citations -> Any@propertydef cite -> Anyprof.cite: get, many, verify, export.
clinical_expertise
Section titled “clinical_expertise”@propertydef clinical_expertise -> strpersonality/clinical_expertise.md; "" when absent.
coverage
Section titled “coverage”@propertydef coverage -> Anyprof.coverage: get, staleness, recent_work, last_updated.
directory
Section titled “directory”@propertydef directory -> Path | NoneThe filesystem directory backing this profile, or None.
This is the only filesystem admission. Only the serve-time derived
caches under .cache/ (which ArtifactStorage does not cover) may
branch on it, and they should use require_directory.
@propertydef edit -> AnyOwner metadata, SOUL, and visibility changes.
expertise
Section titled “expertise”@propertydef expertise -> str@propertydef field -> str | Nonegrants
Section titled “grants”@propertydef grants -> list[GrantRecord]Grant records from sources/grants.jsonld; empty when absent.
has_persona
Section titled “has_persona”@propertydef has_persona -> boolWhether this profile can role-play as a synthesized persona.
True only for a full or deep profile with both a soul and an
expertise narrative, which the persona prompt is built from.
@propertydef index -> Anyprof.index: build, search, search_similar, embedding.
@propertydef level -> strProfile depth tier: "lite" / "full" / "deep".
Read from metadata.level only; there is no fallback.
license
Section titled “license”@propertydef license -> str | NoneReuse terms for the published record.
metadata
Section titled “metadata”@propertydef metadata -> ProfileDocumentThe parsed profile.jsonld document.
@propertydef name -> str@propertydef orcid -> str | NoneDerived from rid; None for a locally-minted identity.
papers
Section titled “papers”@propertydef papers -> list[PaperRecord]persona
Section titled “persona”@propertydef persona -> Anyprof.persona: ask, review, innovate, riff, chat.
profile_id
Section titled “profile_id”@propertydef profile_id -> int | NoneThe store’s record key, when the backing store has one (SQL).
A rid says who a profile is about; a store may hold several records
about one person. profile_id names this record.
provenance
Section titled “provenance”@propertydef provenance -> strWho asserted this profile and on what basis.
@propertydef rid -> strThe identity of this profile: a canonical ORCID or a local: id.
The single join key for every cross-system mapping. Required on disk; a profile without one fails to load.
seed_papers
Section titled “seed_papers”@propertydef seed_papers -> list[SeedPaperRecord]Interest seed papers from sources/seed_papers.jsonld; empty when absent.
Papers that define the person’s interests, never works they wrote: they feed the interest signal (index, centroid, feed, matching) and nothing that lists or synthesizes the person’s own work.
@propertydef slug -> strDisplay handle, derived from the profile directory name.
Not identity: it may collide across registry roots and may be renamed.
Never persist it as a foreign key or join on it. Use rid.
@propertydef soul -> strstorage
Section titled “storage”@propertydef storage -> ArtifactStorageThe storage backend holding every artifact of this profile.
summaries
Section titled “summaries”@propertydef summaries -> Mapping[str, str]summary
Section titled “summary”@propertydef summary -> str | Nonetopics
Section titled “topics”@propertydef topics -> Anyprof.topics: get, relevance.
trials
Section titled “trials”@propertydef trials -> list[TrialRecord]Clinical trials from sources/trials.jsonld; empty when absent.
The optional clinical extension; empty is normal.
Class methods
Section titled “Class methods”from_api
Section titled “from_api”@classmethoddef from_api(url: str, *, slug: str | None = None, token: str | None = None, timeout: float = 60.0, client: Any = None) -> ResearcherProfileConstruct from a live researcher_profiles.api server.
url may be the server root (e.g. http://localhost:8109), in
which case slug must be provided, or a slug-qualified URL like
http://localhost:8109/api/v1/profiles/jane-doe.
The result is read-only, with an HTTP-backed persona and a
search-only index.
from_db
Section titled “from_db”@classmethoddef from_db(engine_or_url: Any, ref: str, *, eager: bool = False) -> ResearcherProfileConstruct from the SQL profile store. Requires the sql extra.
engine_or_url is a SQLAlchemy Engine or a connection URL; ref
is a rid or a slug (rid wins, as everywhere):
prof = ResearcherProfile.from_db("postgresql://…/rp", "jane-doe")from_files
Section titled “from_files”@classmethoddef from_files(path: str | os.PathLike, *, eager: bool = False, validate: bool = False) -> ResearcherProfileConstruct from a local directory or a published profile URL.
path may also be an http(s):// or s3:// URL pointing at
a statically published profile directory, in which case this
delegates to from_url.
validate: run the on-disk format check and raise on any error.
Off by default: most callers tolerate gaps (missing sources/, missing
citations.json, etc.) which are common during profile construction.
Requires a local directory.
from_url
Section titled “from_url”@classmethoddef from_url(url: str, *, eager: bool = False, timeout: float = 30.0, client: Any = None) -> ResearcherProfileConstruct from a published profile directory on a static host.
url points at one profile’s directory, e.g.
https://profiles.example.org/jane-doe or
s3://my-bucket/profiles/jane-doe (public buckets only; the s3
scheme is translated to the HTTPS endpoint). For a live API server,
use from_api instead.
list_remote
Section titled “list_remote”@classmethoddef list_remote(url: str, *, token: str | None = None, timeout: float = 30.0, client: Any = None) -> list[dict[str, Any]]List the profiles a remote server holds, as dicts shaped like ProfileSummary.
Methods
Section titled “Methods”add_post_commit_hook
Section titled “add_post_commit_hook”def add_post_commit_hook(hook: WriteHook) -> NoneRegister a callable to run after a write unit commits successfully.
For fire-and-forget notifications outside the store, where a write
should not fail because another system is down. Fires once per
outermost write_unit. A raising hook is logged at WARNING,
never propagated.
add_pre_commit_hook
Section titled “add_pre_commit_hook”def add_pre_commit_hook(hook: WriteHook) -> NoneRegister a callable to run inside every write unit, before commit.
Hooks run in registration order and receive one WriteContext.
A hook that raises aborts the write (see write_unit).
ProfileStore.add_pre_commit_hook registers one on every
profile a store hands out.
add_seed_papers
Section titled “add_seed_papers”def add_seed_papers(records: list[SeedPaperRecord]) -> tuple[list[SeedPaperRecord], list[tuple[SeedPaperRecord, str]]]Append new seeds and persist them in one write unit.
Returns (added, skipped). Each skipped record carries its reason:
already_seed (same seed_id, OpenAlex id or DOI as a seed),
already_authored (an authored paper has that OpenAlex id or DOI),
or over_cap (the list already holds SEED_PAPERS_MAX).
Nothing is written when nothing is added.
build_manifest
Section titled “build_manifest”def build_manifest(*, write: bool = False) -> list[ArtifactRef]Generate the manifest from what the backend actually holds.
With write=True the result is stored through save_profile,
which stamps dateModified: changed sha256 entries are a real
content change.
def close() -> NoneRelease whatever the backend holds open (an HTTP client, say).
A no-op on a backend with nothing to close, so
with ResearcherProfile.from_api(...) as prof: works everywhere.
content_hash
Section titled “content_hash”def content_hash() -> str"sha256:<hex>" over the canonical document and the SOUL text.
Refreshed inside the write unit before the pre-commit hooks run, so a hook sees post-write content. Identical across backends.
delete_summary
Section titled “delete_summary”def delete_summary(paper_id: str) -> NoneRemove one paper summary body; absent is not an error.
locate
Section titled “locate”def locate(*parts: str) -> strA human-readable locator for a logical artifact, display only.
Never parse, join, or open it. It may be a path, a URL, or a table/row reference.
manifest
Section titled “manifest”def manifest() -> list[ArtifactRef]The manifest as recorded in profile.jsonld (hasPart + subjectOf).
persisted_document
Section titled “persisted_document”def persisted_document() -> dict[str, Any]The serialized document the store currently holds; {} when absent.
The dateModified comparison basis. Unlike metadata, it never
carries unsaved edits.
refresh
Section titled “refresh”def refresh() -> NoneDrop cached reads so the next access re-fetches.
Also asks the backend to drop its own caches.
remove_seed_paper
Section titled “remove_seed_paper”def remove_seed_paper(seed_id: str) -> boolDrop one seed by SeedPaperRecord.seed_id. False when absent.
require_directory
Section titled “require_directory”def require_directory(what: str) -> PathThe backing directory, or raise CapabilityUnavailableError.
rid_or_empty
Section titled “rid_or_empty”def rid_or_empty() -> strThe rid, or "" when no document is readable yet.
A profile being created has no readable document yet.
save_build_state
Section titled “save_build_state”def save_build_state(state: BuildState | None = None) -> NonePersist build state; None persists what is in memory.
Build state is not published content; a read-only backend has none.
save_citations
Section titled “save_citations”def save_citations(data: Any = UNSET) -> NonePersist the citation graph; None deletes it.
Omitting data persists what is in memory.
save_clinical_expertise
Section titled “save_clinical_expertise”def save_clinical_expertise(text: str) -> NonePersist personality/clinical_expertise.md.
save_expertise
Section titled “save_expertise”def save_expertise(text: str | None = None) -> NonePersist the expertise narrative; None persists what is in memory.
save_grants
Section titled “save_grants”def save_grants(grants: list[GrantRecord] | None = None) -> NonePersist the grant records; None persists what is in memory.
save_papers
Section titled “save_papers”def save_papers(papers: list[PaperRecord] | None = None) -> NonePersist the paper corpus; None persists what is in memory.
save_profile
Section titled “save_profile”def save_profile(doc: ProfileDocument | None = None) -> ProfileDocumentValidate, stamp dateModified, and persist the profile document.
doc defaults to the in-memory metadata.
Order is fixed: dump, stamp against the stored document, canonicalize,
re-validate, persist. A document that would fail to load never reaches
the store. Stamping compares serialized dicts (see
researcher_profiles.utils.date_modified); any incoming
dateModified is ignored so the stamp cannot freeze.
Returns the re-parsed ProfileDocument.
save_seed_papers
Section titled “save_seed_papers”def save_seed_papers(seeds: list[SeedPaperRecord] | None = None) -> NonePersist the interest seed papers; None persists what is in memory.
Also records the seed_papers manifest entry in the document when it
is missing (one write unit), so the part has a tier the owner can
change and a push keeps it on every backend.
save_soul
Section titled “save_soul”def save_soul(text: str | None = None) -> NonePersist the SOUL narrative; None persists what is in memory.
save_summary
Section titled “save_summary”def save_summary(paper_id: str, text: str) -> NonePersist one paper summary body.
save_trials
Section titled “save_trials”def save_trials(trials: list[TrialRecord] | None = None) -> NonePersist the clinical trials; None persists what is in memory.
set_paper_contaminated
Section titled “set_paper_contaminated”def set_paper_contaminated(paper_id: str, contaminated: bool) -> boolFlag a paper contaminated. Returns True if the paper exists.
Contamination is build state; the published record is untouched.
to_agent_seed
Section titled “to_agent_seed”def to_agent_seed() -> dict[str, Any]Return the minimal dict an external agent runtime needs to seed an agent.
Identity fields, the expertise/soul bodies, and a paper count. Key
agent rows on rid.
to_dict
Section titled “to_dict”def to_dict(*, include_summaries: bool = False) -> dict[str, Any]Return a JSON-serializable dict representation.
By default summary_ids (only the keys) is included to keep
payloads small. Pass include_summaries=True to substitute
summaries: {id: text} with the full bodies.
validate
Section titled “validate”def validate() -> AnyValidate this profile directory against the on-disk format schemas.
Returns a
researcher_profiles.validate.ProfileValidationReport, which
carries an ok property, the per-artifact results, and the
violations behind them.
write_unit
Section titled “write_unit”def write_unit(kind: str) -> AbstractContextManager[WriteContext]The transactional boundary for one logical write.
See researcher_profiles.profile.write_unit.WriteUnit.open for
the ordering and failure contracts. api.deps and the guardrail
suite monkeypatch this method.
Owner edits
Section titled “Owner edits”EditManager
Section titled “EditManager”class EditManager(profile: ResearcherProfile)Owner-edit policy and mutation surface for one profile.
Methods
Section titled “Methods”add_work
Section titled “add_work”def add_work(record: PaperRecord | dict[str, Any]) -> PaperRecordAdd one work, or replace the one already carrying its paper_id.
patch_metadata
Section titled “patch_metadata”def patch_metadata(patch: dict[str, Any]) -> ProfileDocumentPatch owner-editable metadata fields and persist the document.
patch_work
Section titled “patch_work”def patch_work(paper_id: str, patch: dict[str, Any]) -> PaperRecordPatch one record in sources/papers.jsonld and persist the corpus.
Only fields in EDITABLE_WORK_FIELDS are accepted. A bad value
raises EditError before anything is written. Corpus order is
preserved.
remove_work
Section titled “remove_work”def remove_work(paper_id: str) -> NoneRemove one work from sources/papers.jsonld.
set_soul
Section titled “set_soul”def set_soul(soul: str) -> NoneReplace the free-form SOUL/persona narrative.
set_visibility
Section titled “set_visibility”def set_visibility(*, profile_visibility: str | None = None, artifacts: list[dict[str, Any]] | None = None, sections: list[dict[str, Any]] | None = None) -> tuple[ProfileDocument, int]Set the profile-level, per-artifact, and per-section privacy tiers.
Sections are the inline fields of the document. Tiers are set only here, never through the metadata patch, so there is one privacy implementation.
Exceptions
Section titled “Exceptions”ProfileError
Section titled “ProfileError”class ProfileErrorBases: Exception
Base exception for researcher_profiles.
ProfileLoadError
Section titled “ProfileLoadError”class ProfileLoadError(location: Any, message: str, original: Exception | None = None)Bases: ProfileError
Raised when a stored profile artifact is malformed or unreadable.
location is untyped: a Path, a URL, or a table/row reference,
depending on the backend.
Attributes
Section titled “Attributes”ProfileWriteError
Section titled “ProfileWriteError”class ProfileWriteError(location: Any, message: str, original: Exception | None = None)Bases: ProfileError
Raised when a profile artifact cannot be persisted, or the store is read-only.
location is untyped, as in ProfileLoadError.
Attributes
Section titled “Attributes”CapabilityUnavailableError
Section titled “CapabilityUnavailableError”class CapabilityUnavailableErrorBases: ProfileError, NotImplementedError
A capability needs something this backend does not have.
Both a ProfileError and a NotImplementedError, so either
except clause catches it.
ProfileValidationError
Section titled “ProfileValidationError”class ProfileValidationError(report: Any)Bases: ProfileError
Raised when from_files(..., validate=True) produces a non-ok report.
Attributes
Section titled “Attributes”PersonaUnavailableError
Section titled “PersonaUnavailableError”class PersonaUnavailableError(slug: str)Bases: ProfileError
Raised when a persona method is called on a profile with no persona.
The persona methods (.ask/.review/.innovate/.riff) need
both expertise.md and SOUL.md non-empty (see
ResearcherProfile.has_persona). A client-visible precondition failure,
not a server error, so a lite profile never role-plays an empty persona.
Attributes
Section titled “Attributes”Export for knowledge bases
Section titled “Export for knowledge bases”researcher_profiles.profile.export turns one profile into a single block of prose plus
the metadata that travels with it, so a connector pushing profiles into a
knowledge base (KB) never has to open profile.jsonld, expertise.md, SOUL.md
and papers.jsonld itself, re-implement the privacy rule, or invent a change
detector. The idempotency contract behind content_hash is explained in
docs-dev/rp-sdk/explanation/sdk-architecture.md.
Entry points
Section titled “Entry points”render_export_text
Section titled “render_export_text”def render_export_text(profile: ResearcherProfile, options: ExportOptions | None = None, *, papers: Sequence[PaperRecord] | None = None) -> strRender one profile as a single deterministic markdown blob.
A synthesized narrative (identity, expertise, interests, career, then the
expertise.md and SOUL.md bodies verbatim) followed by the prose of
selected papers.
Deterministic by contract: two renders of an unchanged directory are
byte-identical. ProfileExportBundle.content_hash depends on that.
Pass papers to skip selection.
Raises ExportVisibilityError when the profile document’s
visibility is above public and options.allow_nonpublic is not set.
select_export_papers
Section titled “select_export_papers”def select_export_papers(profile: ResearcherProfile, options: ExportOptions | None = None) -> list[PaperRecord]The topic-representative subset of a profile’s papers, deterministically.
Filter (untitled, contaminated, bodyless, non-public), score
(authorship + impact + recency), then diversify greedily so the selection
spans the researcher’s topics. No embeddings; a caller with vectors should
pass its own list to render_export_text.
Returned in presentation order (year desc, then title), which is not selection order.
build_export_bundle
Section titled “build_export_bundle”def build_export_bundle(profile: ResearcherProfile, options: ExportOptions | None = None, *, papers: Sequence[PaperRecord] | None = None, now: str | None = None) -> ProfileExportBundleRender a profile and wrap it with the metadata a knowledge base needs.
A connector upserts keyed on rid and uses content_hash as
the change detector, so re-running a backfill is a no-op.
explore_url
Section titled “explore_url”def explore_url(profile_url: str | None, *, explore_base: str | None = None) -> str | NoneThe browser-app backlink for a published profile directory.
Returns None unless both a profile URL and an explore base are given.
Shape: <base>/p?u=<directory>, with profile.jsonld stripped, a
trailing slash, and percent-encoding.
export_content_hash
Section titled “export_content_hash”def export_content_hash(payload: Mapping[str, Any]) -> str"sha256:<64 hex>" over payload in canonical JSON form.
Reproducible across machines and Python versions.
export_paper_body
Section titled “export_paper_body”def export_paper_body(profile: ResearcherProfile, paper: PaperRecord, options: ExportOptions | None = None) -> tuple[str, str]Return (text, source) for one paper’s prose.
source is "summary" or "abstract". A summary body drops its
frontmatter and the [abstract-only] marker. Returns ("", "abstract")
when the paper has no usable body.
ExportOptions
Section titled “ExportOptions”ExportOptions
Section titled “ExportOptions”class ExportOptionsBases: BaseModel
Every knob the export surface has. New knobs are fields with defaults.
Attributes
Section titled “Attributes”allow_nonpublic(bool) =False: Permit export of a profile whose document visibility is above public.char_budget(int | None) =120000: Soft cap on total blob characters. Whole paper blocks are dropped from the end; a body is never truncated mid-text. None disables it.diversity(float) =0.7: Overlap-penalty weight in the greedy paper selector (0..1).explore_base(str | None) =None: Base URL of a profile browser app. When unset, the bundle carries no explore_url.include_expertise_doc(bool) =True: Include personality/expertise.md.include_soul(bool) =True: Include personality/SOUL.md.max_papers(int) =40: Cap on papers whose text enters the blob.prefer_summaries(bool) =True: Use a paper’s sources/summaries/.summary.md body when present, falling back to its abstract. profile_url(str | None) =None: Override for the published profile URL (default: metadata.url).
ProfileExportBundle
Section titled “ProfileExportBundle”ExportPaperRef
Section titled “ExportPaperRef”class ExportPaperRefBases: BaseModel
One paper whose prose is inside the rendered text.
Attributes
Section titled “Attributes”body_source(Literal[‘summary’, ‘abstract’])doi(str | None): Normalized and bare (10.xxxx/yyy), never a resolver URL.paper_id(str | None)title(str)year(int | None)
ProfileExportBundle
Section titled “ProfileExportBundle”class ProfileExportBundleBases: BaseModel
One profile, rendered for a knowledge base: the text plus what travels with it.
dois and papers are different things and must not be conflated.
dois is the profile’s full corpus DOI list: every paper in
sources/papers.jsonld, normalized and de-duplicated, in document order.
A KB uses it to link this profile to works it already holds, whether or not
their prose is in this export. papers is the much smaller subset whose
prose is actually inside text.
content_hash is the idempotency key. It covers every field except
itself and built_at (including summary, which is real
content, so an edited summary triggers a refresh), hence two builds seconds
apart hash identically and a KB upserts only when the hash changes.
Attributes
Section titled “Attributes”affiliation(str | None)built_at(str)conforms_to(str)content_hash(str)dois(list[str])explore_url(str | None)export_version(int)field(str | None)level(str)license(str | None)name(str)orcid(str | None)paper_count(int)papers(list[ExportPaperRef])profile_url(str | None)provenance(str)rid(str)sdk_version(str)slug(str)summary(str | None): The profile’s one-paragraph self-description (metadata.summary), the same value rendered into the blob’s## Overview. Carried as its own field so a connector can use it as a short document description without re-openingprofile.jsonld. This package owns profile reading.text(str)text_chars(int)visibility(str)
Exceptions
Section titled “Exceptions”ExportError
Section titled “ExportError”class ExportErrorBases: ProfileError
Base error for the knowledge-base export surface.
ExportVisibilityError
Section titled “ExportVisibilityError”class ExportVisibilityErrorBases: ExportError
The profile is not public and the caller did not opt in.
Pass allow_nonpublic=True (CLI: --allow-nonpublic) when the
destination is authorized to hold the profile’s tier.
Cross-profile analytics (store.match, store.centroids, store.indexes)
Section titled “Cross-profile analytics (store.match, store.centroids, store.indexes)”There is no aggregate object over a store: a collection of profiles is the
store. The three cross-profile concerns hang off it as accessors, the same way
prof.cite hangs off a profile. They require the [vectors,st] extras (they
need numpy) and are imported on first access, so a core-only install still
uses the store for everything else.
Ranking and centroids also need the VectorStore capability; a store without
it raises CapabilityUnavailableError naming what to do instead.
DuplicateIdentityError
Section titled “DuplicateIdentityError”class DuplicateIdentityErrorBases: ValueError
Two profiles in one directory store claim the same rid.
Only the filesystem backend raises it: a directory corpus holds one record
per person. A SQL store refuses the second record at write time instead,
and a site’s by-rid.json cannot hold two entries under one key.
store.centroids (centroids and the query backend)
Section titled “store.centroids (centroids and the query backend)”CentroidManager
Section titled “CentroidManager”class CentroidManager(store: VectorStore, rostered: _RosterCache)store.centroids, the centroid matrix and the query embedder.
Kept together because a query vector is only comparable to the matrix when it comes from the backend the indexes were built with.
Properties
Section titled “Properties”backend
Section titled “backend”@propertydef backendThe backend queries are embedded with, resolved once and kept.
The environment override wins, then the store’s detected index backend, then whatever the first profile resolves to.
cache_path
Section titled “cache_path”@propertydef cache_path -> Optional[Path]<root>/.cache/centroids.npz, or None without a root.
matrix
Section titled “matrix”@propertydef matrix -> np.ndarrayL2-normalized centroids, one row per profile, slug-ordered.
Methods
Section titled “Methods”embed_query
Section titled “embed_query”def embed_query(text: str) -> np.ndarraytext as a unit vector in the same space as matrix.
invalidate
Section titled “invalidate”def invalidate() -> NoneDrop the in-memory matrix and unlink the .npz.
snapshot
Section titled “snapshot”def snapshot() -> tuple[_Roster, np.ndarray](roster, matrix), guaranteed row-aligned.
Row i is roster.slugs[i]. Fetching the two separately could
pair a roster with a matrix rebuilt after a write in between.
store.match (ranking and analysis)
Section titled “store.match (ranking and analysis)”MatchManager
Section titled “MatchManager”class MatchManager(store: VectorStore, rostered: _RosterCache)store.match: ranking, diversification, topics, clustering.
Properties
Section titled “Properties”topics_cache_path
Section titled “topics_cache_path”@propertydef topics_cache_path -> Optional[Path]<root>/.cache/topics.json (a view of topics), or None without a root.
Methods
Section titled “Methods”cluster
Section titled “cluster”def cluster(k: int | None = None) -> dict[str, list[ResearcherProfile]]Group profiles by centroid; each group is named for its exemplar.
diversify
Section titled “diversify”def diversify(matches: list[Match], k: int = 5, lambda_: float = 0.5) -> list[Match]Re-order by maximal marginal relevance: relevant but not redundant.
invalidate
Section titled “invalidate”def invalidate() -> NoneUnlink the topics view.
def rank(text: str, k: int = 5, *, prefilter: int = 10, normalize: bool = True, require_topics: list[str] | None = None, diversify: bool = True, lambda_: float = 0.5, topk_chunks: int = 5, interests: Sequence[ResearchInterest] | None = None, topic_alpha: float = 0.2, viewer: Callable[[ResearcherProfile], ViewerTier] | None = None) -> list[Match]Rank every profile in the store against a free-text query.
require_topics keeps only profiles claiming one of them: a topic
label (the cached topic index), or an OpenAlex topic id (T10222)
the profile’s typed research interests carry.
interests are the query side’s typed interests (a query profile’s
research_interests). After the chunk re-rank, each candidate’s
OpenAlex topics are scored against them with
topic_score, and topic_alpha
times that is added to its score. A candidate on a topic the query
declared -1 is dropped. The shared topics are reported in
evidence.matched_topics.
viewer maps a profile to the tier the caller may see of it. When
given, chunks above that tier take no part in the score or the
evidence: the chunk half of the score, top_papers and
top_chunks are computed as if those chunks were not indexed.
None (in-process use) scores every chunk.
topics
Section titled “topics”def topics() -> dict[str, list[ResearcherProfile]]Topic label -> the profiles claiming it. Also written to disk.
store.indexes (embedding-index maintenance)
Section titled “store.indexes (embedding-index maintenance)”The object is analytics.IndexFleetManager, distinct from the per-profile
profile.index.IndexManager that prof.index hands back.
IndexFleetManager
Section titled “IndexFleetManager”class IndexFleetManager(store: ProfileStore, rostered: _RosterCache)store.indexes: build and report on every profile’s index.
Directory-only: on a store whose profiles have no directory (SQL, HTTP)
every method raises
CapabilityUnavailableError.
Methods
Section titled “Methods”coverage
Section titled “coverage”def coverage() -> list[dict[str, Any]]Per-profile index / topic / calibration status, slug-ordered.
rebuild_all
Section titled “rebuild_all”def rebuild_all(force: bool = False) -> dict[str, Any]Rebuild every profile’s index, then drop everything derived from it.
Fail-soft per profile: one broken profile is reported as None and
does not stop the rest.
def stats() -> dict[str, IndexStats]slug -> IndexStats, one index open per profile.
ApiArtifactStorage and StaticArtifactStorage
Section titled “ApiArtifactStorage and StaticArtifactStorage”researcher_profiles.client provides the two HTTP backends. It requires the
[client] extra. Neither is a profile: build an ordinary ResearcherProfile
over one with ResearcherProfile.from_api(url) or .from_url(url), and it
behaves like a local profile, with the same properties and method signatures.
ApiArtifactStorage
Section titled “ApiArtifactStorage”class ApiArtifactStorage(slug: str, base_url: str, token: Optional[str] = None, *, timeout: float = 60.0, client: Any = None)Bases: ReadOnlyArtifactStorage
A profile served by a live researcher_profiles.api server.
Built by ResearcherProfile.from_api(...). Reads come off the combined
detail payload where the API has one, and off the dedicated routes
otherwise.
Attributes
Section titled “Attributes”Properties
Section titled “Properties”directory
Section titled “directory”@propertydef directory -> None@propertydef key -> strrid_hint
Section titled “rid_hint”@propertydef rid_hint -> None@propertydef slug -> strMethods
Section titled “Methods”artifact_bytes
Section titled “artifact_bytes”def artifact_bytes(content_url: str) -> Optional[bytes]artifact_text
Section titled “artifact_text”def artifact_text(content_url: str) -> Optional[str]Only what v1 serves: soul, expertise, and paper summaries.
Returns None otherwise, rather than an invented empty body. The v1
API has no route for an arbitrary artifact’s bytes.
build_manifest
Section titled “build_manifest”def build_manifest() -> tuple[list[ArtifactRef], list[ArtifactRef]]The manifest the server holds (GET .../files); there is nothing to walk.
def close() -> Nonecollection_envelope
Section titled “collection_envelope”def collection_envelope(content_url: str) -> dictcontent_hash
Section titled “content_hash”def content_hash() -> str"sha256:<hex>" over the served document + soul.
Computed from the parsed document, not raw bytes, because the API serves a document, not a file.
def index(profile: ResearcherProfile) -> _RemoteIndexsearch over HTTP; everything else needs the local sqlite handle.
load_citations
Section titled “load_citations”def load_citations() -> Anyload_document
Section titled “load_document”def load_document() -> ProfileDocumentThe served metadata with the manifest put back, as one document.
The record carries no manifest (it has its own route) and two counts that are not document fields. Both are fixed here so the document validates.
load_expertise
Section titled “load_expertise”def load_expertise() -> strload_grants
Section titled “load_grants”def load_grants() -> list[GrantRecord]load_papers
Section titled “load_papers”def load_papers() -> list[PaperRecord]The whole records off the works file, indexed by the paged rows.
The rows say which summaries this caller may fetch; the works file
(content/sources/papers.jsonld, gated by the same tier as the rows)
carries every bibliographic field in one read. A server that will not
serve the file still answers with records built from the rows.
load_soul
Section titled “load_soul”def load_soul() -> strload_summaries
Section titled “load_summaries”def load_summaries() -> Mapping[str, str]locate
Section titled “locate”def locate(*parts: str) -> strDisplay-only locator: the API URL naming this artifact.
persona
Section titled “persona”def persona(profile: ResearcherProfile) -> _RemotePersonaHTTP-backed persona: the server owns model, corpus and refusals.
refresh
Section titled “refresh”def refresh() -> NoneDrop cached immutable resources so the next access refetches.
StaticArtifactStorage
Section titled “StaticArtifactStorage”class StaticArtifactStorage(url: str, *, timeout: float = 30.0, client: Any = None)Bases: ReadOnlyArtifactStorage
A published profile directory on a dumb static host (Pages, S3, …).
Built by ResearcherProfile.from_url(...). Fetches the published files
with one lazy GET per artifact. Summaries are enumerated from the manifest
in profile.jsonld, since a static host has no directory listing.
persona and index are not overridden: the local managers are built,
and the index’s require_directory refuses with the
install_profile() message.
Attributes
Section titled “Attributes”Properties
Section titled “Properties”directory
Section titled “directory”@propertydef directory -> None@propertydef key -> strrid_hint
Section titled “rid_hint”@propertydef rid_hint -> None@propertydef slug -> strMethods
Section titled “Methods”artifact_bytes
Section titled “artifact_bytes”def artifact_bytes(content_url: str) -> Optional[bytes]artifact_text
Section titled “artifact_text”def artifact_text(content_url: str) -> Optional[str]Whatever the host serves at that relative path.
A published static directory is the manifest’s address space, so any
contentUrl is fetchable, subject to the host’s own access rules,
which surface as PermissionError.
build_manifest
Section titled “build_manifest”def build_manifest() -> tuple[list[ArtifactRef], list[ArtifactRef]]The recorded manifest: a static host has no directory to walk.
def close() -> Nonecollection_envelope
Section titled “collection_envelope”def collection_envelope(content_url: str) -> dictcontent_hash
Section titled “content_hash”def content_hash() -> str"sha256:<hex>" over the fetched document + soul.
load_citations
Section titled “load_citations”def load_citations() -> Anyload_document
Section titled “load_document”def load_document() -> ProfileDocumentload_expertise
Section titled “load_expertise”def load_expertise() -> strload_grants
Section titled “load_grants”def load_grants() -> list[GrantRecord]load_papers
Section titled “load_papers”def load_papers() -> list[PaperRecord]load_soul
Section titled “load_soul”def load_soul() -> strload_summaries
Section titled “load_summaries”def load_summaries() -> Mapping[str, str]locate
Section titled “locate”def locate(*parts: str) -> strDisplay-only locator: the published URL naming this artifact.
rank_against (module-level)
Section titled “rank_against (module-level)”rank_against
Section titled “rank_against”def rank_against(base_url: str, query: str, *, k: int = 5, token: Optional[str] = None, prefilter: int = 10, require_topics: Optional[list[str]] = None, diversify: bool = True, lambda_: float = 0.5, topk_chunks: int = 5, normalize: bool = True, include_chunks: bool = False, interests: Optional[list[dict]] = None, topic_alpha: float = 0.2, timeout: float = 60.0, client: Any = None) -> list[dict]Rank indexed profiles on a remote server against query.
interests are the query side’s typed ResearchInterest entries (as
JSON dicts); each match’s evidence.matched_topics names the OpenAlex
topics that moved its score.
POSTs to /api/v1/match and returns the parsed matches list (plain
dicts shaped like MatchResult: {slug, name, orcid, score, evidence{...}}).
resolve_rid (module-level)
Section titled “resolve_rid (module-level)”resolve_rid
Section titled “resolve_rid”def resolve_rid(base_url: str, *, rid: Optional[str] = None, name: Optional[str] = None, affiliation: Optional[str] = None, create_new: bool = False, token: Optional[str] = None, timeout: float = 30.0, client: Any = None) -> ResolveResultResolve a person descriptor to a rid against the identity authority.
Calls POST /api/v1/identity/resolve. Resolution is idempotent
server-side: the same person resolved from two services gets the same
rid.
rid is an ORCID or a local: id the resolver minted. token must
carry the resolve scope (falls back to the
RESEARCHER_PROFILES_TOKEN env var). A rid of None on the
result means the name was undecidable. Inspect candidates and
re-call with the chosen profile’s rid, or with create_new=True
when none of the candidates is your person (the server mints a fresh
identity; never guess between candidates).
OpenAlex parser
Section titled “OpenAlex parser”researcher_profiles.openalex does pure record parsing, with no HTTP. It is
available in core.
parse_work
Section titled “parse_work”def parse_work(raw: dict) -> PaperRecord | NoneParse a raw OpenAlex work dict into a PaperRecord.
paper_id is left None for the caller to fill. cited_by_count,
pmid and pmcid are carried as extra fields.
Returns:
A PaperRecord (status="pending"), or None when the work has
no title or no publication year (not usable).
decode_abstract_inverted_index
Section titled “decode_abstract_inverted_index”def decode_abstract_inverted_index(idx: dict[str, list[int]] | None) -> strReconstruct an abstract string from an OpenAlex abstract_inverted_index.
The OpenAlex inverted index format maps each word to the list of positions (0-based) where that word appears in the abstract:
{"Despite": [0], "decades": [1], "of": [2, 20, 38], ...}A position claimed by two words keeps the first (sorted by word) and logs a warning. Gaps are skipped. Whitespace is collapsed.
Returns:
The reconstructed abstract string, or "" when idx is empty or
malformed. Never raises.
to_work_dict
Section titled “to_work_dict”def to_work_dict(raw: dict) -> dictThe {title, abstract, year, doi, openalex_id, coauthors} lite shape.
to_normalized_dict
Section titled “to_normalized_dict”def to_normalized_dict(raw: dict) -> dictSame as to_work_dict but with authors in place of coauthors.
Embeddings store
Section titled “Embeddings store”researcher_profiles.embeddings is a per-profile vector store. It requires
the [vectors,st] extras.
SqliteEmbeddingIndex
Section titled “SqliteEmbeddingIndex”class SqliteEmbeddingIndex(profile_dir: str | os.PathLike, *, profile_document: Any = None, corpus_only: bool = False)sqlite-vec index for a single profile.
Attributes
Section titled “Attributes”SCHEMA_VERSION=INDEX_SCHEMA_VERSIONcorpus_only: Leave out summaries of papers not in sources/papers.jsonld (seeenumerate_source_chunks).db_pathindex_dirprofile_dir
Properties
Section titled “Properties”backend_spec
Section titled “backend_spec”@propertydef backend_spec -> strThe embedding model this index was built with; "" when unbuilt.
Methods
Section titled “Methods”build_index
Section titled “build_index”def build_index(*, force: bool = False, backend: EmbeddingBackend | str | None = None) -> IndexReportcentroid
Section titled “centroid”def centroid()The profile vector (blend_centroid), npz-cached.
Imported lazily: numpy is the vectors extra, and the file-facts
methods must work without it.
counts
Section titled “counts”def counts() -> tuple[int, int](n_chunks, n_papers).
Papers counts DISTINCT source_id over PAPER_CHUNK_TYPES,
so a lite profile’s abstracts count as papers.
exists
Section titled “exists”def exists() -> boolTrue when embeddings.sqlite is present.
index_meta
Section titled “index_meta”def index_meta() -> dict[str, str]index_meta as a dict; {} when there is no readable index.
def mtime() -> float | NoneThe index file’s mtime, or None when it is absent.
search
Section titled “search”def search(query: str, k: int = 5, filter: dict | None = None) -> list[SearchHit]search_similar
Section titled “search_similar”def search_similar(source_type: str, source_id: str, chunk_index: int = 0, k: int = 5) -> list[SearchHit]def stats() -> IndexStatsexists + counts + index_meta + mtime.
build_index
Section titled “build_index”def build_index(profile, *, force: bool = False, backend=None, corpus_only: bool = False) -> IndexReportBuild (or refresh) the embedding index for a profile.
profile is a ResearcherProfile or a profile directory path.
corpus_only leaves out summaries of papers not in
sources/papers.jsonld.
researcher_profiles.profile.index.IndexManager is what prof.index hands
back: build, search, search_similar, embedding. Distinct from
analytics.IndexFleetManager (above), which operates over every profile in a
root rather than one profile’s own index.
IndexManager
Section titled “IndexManager”class IndexManager(profile)prof.index: the per-profile embedding index.
Needs a local directory: the index is a .cache/embeddings.sqlite handle.
Methods
Section titled “Methods”def build(*, force: bool = False, backend=None) -> IndexReportBuild (or refresh) this profile’s embedding index.
embedding
Section titled “embedding”def embedding(kind: str = 'centroid')The profile-level vector: centroid / summary / expertise.
search
Section titled “search”def search(query: str, k: int = 5, filter: dict | None = None)Semantic search over this profile’s chunks.
search_similar
Section titled “search_similar”def search_similar(source_type: str, source_id: str, chunk_index: int = 0, k: int = 5)Chunks nearest to one this profile already holds.
SearchHit
Section titled “SearchHit”class SearchHit(text: str, source_type: str, source_id: str, chunk_index: int, section: str | None, cosine: float, score: float, meta: dict = dict())Attributes
Section titled “Attributes”chunk_index(int)cosine(float)meta(dict) =field(default_factory=dict)score(float)section(str | None)source_id(str)source_type(str)text(str)
Methods
Section titled “Methods”to_dict
Section titled “to_dict”def to_dict() -> dict[str, Any]IndexReport
Section titled “IndexReport”class IndexReport(added: int = 0, updated: int = 0, skipped: int = 0, removed: int = 0, backend_name: str = '', duration_s: float = 0.0)Attributes
Section titled “Attributes”added(int) =0backend_name(str) =''duration_s(float) =0.0removed(int) =0skipped(int) =0updated(int) =0
Methods
Section titled “Methods”to_dict
Section titled “to_dict”def to_dict() -> dict[str, Any]Exceptions
Section titled “Exceptions”MissingEmbeddingBackendError
Section titled “MissingEmbeddingBackendError”class MissingEmbeddingBackendErrorBases: RuntimeError
Raised when the chosen embedding backend is not importable / usable.
IndexBackendMismatchError
Section titled “IndexBackendMismatchError”class IndexBackendMismatchErrorBases: RuntimeError
Raised when the existing index was built with a different backend.
Pass force=True to drop and recreate.
IndexNotBuiltError
Section titled “IndexNotBuiltError”class IndexNotBuiltErrorBases: RuntimeError
Raised when an index is read but none exists yet at that path.
Result dataclasses
Section titled “Result dataclasses”researcher_profiles.models.results holds the return types for capability methods.
PersonaResponse
Section titled “PersonaResponse”class PersonaResponse(text: str, citations: list[CitationRef], model: str, usage: dict, raw: Any, request_id: Optional[str] = None, refused: bool = False, refusal_reason: Optional[str] = None, grounded: bool = True)What every persona method returns: .ask, .review, Chat.send.
grounded is False when the model cited a paper_id that is not in
this profile’s paper set: the answer may still be useful, but it left the
corpus. refused is True when strict-corpus retrieval came back too
weak to answer from and the persona declined rather than improvised;
refusal_reason then carries the retrieval score and threshold. A refusal
is a successful call, not an error.
raw is the provider’s own response object. It is local-only: a
response that arrived over the HTTP API has raw=None.
Attributes
Section titled “Attributes”citations(list[CitationRef])grounded(bool) =Truemodel(str)raw(Any)refusal_reason(Optional[str]) =Nonerefused(bool) =Falserequest_id(Optional[str]) =Nonetext(str)usage(dict)
class Idea(hypothesis: str, approach: str, rationale: str, related_works: list[str])One proposed research direction from ResearcherProfile.innovate.
Attributes
Section titled “Attributes”Methods
Section titled “Methods”resolve_citations
Section titled “resolve_citations”def resolve_citations(profile) -> dict[str, Any]Map related_works citation keys to paper records (or None).
Returns a mapping {citation_key: PaperRecord | None}. Unknown keys
map to None rather than raising.
class Riff(angle: str, text: str, related_work: Optional[str] = None)One brainstorm fragment from ResearcherProfile.riff.
Attributes
Section titled “Attributes”CitationRef
Section titled “CitationRef”class CitationRef(paper_id: str, relevance: float | None = None, span: str | None = None)A lightweight reference to a paper used as evidence for a response.
Attributes
Section titled “Attributes”Methods
Section titled “Methods”to_dict
Section titled “to_dict”def to_dict() -> dictCitation
Section titled “Citation”class Citation(paper_id: str, title: str, authors: list[str] = list(), year: int | None = None, venue: str | None = None, doi: str | None = None, url: str | None = None, pdf_url: str | None = None, abstract: str | None = None)A fully-expanded citation record for a single paper.
Attributes
Section titled “Attributes”abstract(str | None) =Noneauthors(list[str]) =field(default_factory=list)doi(str | None) =Nonepaper_id(str)pdf_url(str | None) =Nonetitle(str)url(str | None) =Nonevenue(str | None) =Noneyear(int | None) =None
Methods
Section titled “Methods”to_bibtex
Section titled “to_bibtex”def to_bibtex() -> strto_csl
Section titled “to_csl”def to_csl() -> strto_dict
Section titled “to_dict”def to_dict() -> dictclass Match(profile: Any, score: float, evidence: MatchEvidence, explanation: Optional[str] = None)One profile match against a query, with score, evidence, and lazy explanation.
Attributes
Section titled “Attributes”evidence(MatchEvidence)explanation(Optional[str]) =Noneprofile(Any)score(float)
Methods
Section titled “Methods”explain
Section titled “explain”def explain(query: str = '', *, client: Any = None) -> strProduce a short LLM-backed explanation of why this profile matched.
Cached on first call. Subsequent calls return the cached value.
to_dict
Section titled “to_dict”def to_dict() -> dict[str, Any]MatchEvidence
Section titled “MatchEvidence”class MatchEvidence(top_chunks: list[Any], top_papers: list[str], overlapping_topics: list[str], centroid_score: float, matched_topics: list[str] = list())Evidence supporting a profile match for a query.
Attributes
Section titled “Attributes”centroid_score(float)matched_topics(list[str]) =field(default_factory=list): OpenAlex topic ids (T…) the profile shares with the query’s typed interests, the ones that moved its score. Empty without query interests.overlapping_topics(list[str])top_chunks(list[Any])top_papers(list[str])
Methods
Section titled “Methods”to_dict
Section titled “to_dict”def to_dict() -> dict[str, Any]class Topic(label: str, weight: float, paper_ids: list[str] = list(), chunk_ids: list[int] = list())A topic cluster extracted from a profile’s chunk embeddings.
Attributes
Section titled “Attributes”chunk_ids(list[int]) =field(default_factory=list)label(str)paper_ids(list[str]) =field(default_factory=list)weight(float)
Methods
Section titled “Methods”to_dict
Section titled “to_dict”def to_dict() -> dict[str, Any]Coverage
Section titled “Coverage”class Coverage(name: str, affiliation: str | None, one_liner: str | None, topics: list[str], expertise_summary: str, year_range: Optional[tuple[int, int]], paper_count: int, last_updated: datetime, staleness_label: str, suggested_questions: list[str], coverage_caveats: list[str])Landing-page metadata: what a profile covers and how fresh it is.
Attributes
Section titled “Attributes”affiliation(str | None)coverage_caveats(list[str])expertise_summary(str)last_updated(datetime)name(str)one_liner(str | None)paper_count(int)staleness_label(str)suggested_questions(list[str])topics(list[str])year_range(Optional[tuple[int, int]])
Methods
Section titled “Methods”to_dict
Section titled “to_dict”def to_dict() -> dictGenerativeParseError
Section titled “GenerativeParseError”class GenerativeParseError(message: str, raw_text: str = '')Bases: ProfileError
Raised when the LLM repeatedly fails to return valid JSON.
Attributes
Section titled “Attributes”PersonaUnavailableError is documented under
ResearcherProfile exceptions above.
ProfileStore (researcher_profiles.store)
Section titled “ProfileStore (researcher_profiles.store)”The collection-level interface: a set of profiles. Each profile’s artifacts
live one level down in ArtifactStorage. It is what an HTTP app, an
ingest path, or a management host is handed, and it is what
create_app takes. The “one filesystem
admission” (store.root) is explained in
docs-dev/rp-sdk/explanation/sdk-architecture.md.
ProfileStore
Section titled “ProfileStore”class ProfileStoreBases: Protocol
A set of profiles, whatever they are stored in.
Every method that takes a ref takes a slug (a retired slug
included where the backend keeps aliases) or a rid (a retired rid
follows its alias), and, on a backend that has record keys (SQL), the
integer key (#<id> as a string). Resolution is the store’s job, not
the caller’s. A rid names the one record about that person. A store that
holds several records about one person
(SqlProfileStore(one_profile_per_rid=False)) does not resolve a bare
rid: profiles_about lists the records, and the host says which
one a rid means.
Properties
Section titled “Properties”location
Section titled “location”@propertydef location -> strA human-readable locator for this store. Display only.
A directory path, a database URL. Never parse it or join to it.
@propertydef root -> Optional[Path]The filesystem root when this store is a directory, else None.
The one filesystem admission. This is the whole sanctioned set of
readers that branch on root, and nothing else may:
<root>/.cache/graph.sqlite(the co-authorship graph), which has no interface yet. Read inapi.deps, which turns aNoneroot into a 503 for graph-shaped features, or materializes a temp directory.- The optional write-through memos
<root>/.cache/centroids.npzand<root>/.cache/topics.json, which a store without a directory simply does without (analytics.centroids,analytics.match, andanalytics.roster, which re-exposes the value as_Roster.rootfor those two). - Staging and archiving paths that want to
os.renameon the store’s own filesystem (api.upload,api.routers.push); each already has a scratch-space fallback forNone. - Invalidating those same store-wide caches after a write
(
api.service.Service.invalidate). They sit outsideArtifactStorage, so nothing else can drop them; aNoneroot means there are none.
tests/test_guardrails.py holds the allowlist to match, so a new
reader fails the suite until it is added here too.
Methods
Section titled “Methods”add_post_commit_hook
Section titled “add_post_commit_hook”def add_post_commit_hook(hook: WriteHook) -> NoneRegister a callable to run after a write commits successfully.
Same reach as add_pre_commit_hook, but it cannot abort the
write, which already landed. For fire-and-forget notifications.
add_pre_commit_hook
Section titled “add_pre_commit_hook”def add_pre_commit_hook(hook: WriteHook) -> NoneRegister a callable to run inside every write, before commit.
Registered on the store, not an HTTP app, so writes that bypass the
routes still fire it. Applies to profiles already handed out too, so
order relative to a first get does not matter.
alias_slugs
Section titled “alias_slugs”def alias_slugs() -> set[str]Slugs of retired profiles. They stay reserved: allocate around them.
artifact_bytes
Section titled “artifact_bytes”def artifact_bytes(ref: str, content_url: str) -> bytesOne manifest artifact’s bytes, addressed by its contentUrl.
Raises ProfileNotFoundError when the profile or the artifact is
absent. Applies no privacy policy; the caller must.
commit_directory
Section titled “commit_directory”def commit_directory(slug: str, staging: Path, *, build_missing_index: bool = False) -> IngestResultMake a fully-staged profile directory live in this store.
staging is a complete profile directory (profile.jsonld at its
root): the interchange format, not the storage format. “Live” is an
atomic rename for the filesystem, a transaction for SQL. A replace (the
slug is live) keeps the record and its record key, and refuses a
staged rid that differs.
Where slugs have aliases (SQL), a former slug is a replace of the
record it leads to when that record is about the staged rid; the
record keeps its live slug, which the result names.
build_missing_index asks for a best-effort embedding-index build
afterwards; a backend with no filesystem ignores it.
Raises UploadError if the staged directory does not load, and
RetiredRidError for a retired rid or slug.
content_hash
Section titled “content_hash”def content_hash(ref: str) -> str"sha256:<hex>" over the canonical document and the SOUL text.
Refreshed inside the write unit before the pre-commit hooks run, so a
hook sees post-write content. Identical across backends; see
researcher_profiles.store.db.content_hash_for.
create
Section titled “create”def create(document: ProfileDocument, *, slug: str) -> ResearcherProfileCreate a new profile from a validated document. Returns it.
Runs in one write unit of kind "create", so a pre-commit hook can
write a host’s own rows about the new record (an ownership row, an
audit row) in the same transaction, with no orphan window.
Raises ProfileWriteError if slug is taken, or if the rid
already has a record on a store that holds one record per person, and
RetiredRidError for a retired rid or slug.
create_bundle
Section titled “create_bundle”def create_bundle(document: ProfileDocument, *, slug: str, expertise: Optional[str] = None, soul: Optional[str] = None, artifacts: Optional[dict[str, str]] = None) -> ResearcherProfileCreate a profile and all of its authored artifacts in one write unit.
The document, persona documents, every artifact, the hook-written host rows and the derived digest land together, so no half-built profile is ever readable.
artifacts maps a manifest contentUrl to its text body; each key
must appear in the document’s hasPart or subjectOf.
Whether the unit is genuinely atomic is the backend’s to say
(WriteContext.atomic): SQL commits or rolls back, the filesystem
cleans up after itself instead.
Raises as create does.
delete
Section titled “delete”def delete(ref: str | int) -> strRemove a profile and everything belonging to it. Returns its rid.
document_bytes
Section titled “document_bytes”def document_bytes(ref: str) -> bytesThe canonical profile.jsonld bytes, exactly as persisted.
Served byte for byte, so the conformsTo claim holds for the file a
crawler retrieves.
def evict(ref: str) -> NoneDrop any cached view of ref so the next get reloads.
A no-op on a store that does not cache. Cache invalidation only; dependent state belongs in a pre-commit hook.
exists
Section titled “exists”def exists(ref: str | int) -> boolWhether ref names a profile in this store.
export_directory
Section titled “export_directory”def export_directory(ref: str, dest: Path) -> PathMaterialize a profile as a directory at dest. Returns dest.
The inverse of commit_directory.
def get(ref: str | int) -> ResearcherProfileLoad a profile. Raises ProfileNotFoundError.
The returned profile carries every hook registered through
add_pre_commit_hook, so any write through it fires them.
held_artifacts
Section titled “held_artifacts”def held_artifacts(ref: str) -> dict[str, int | None]{contentUrl: stored size in bytes} for every body this store holds.
A manifest row can outlive its body (a push withholds full text by
default), so “listed” is not “fetchable”. Reads no body. A size is
None when the store cannot say cheaply. Raises
ProfileNotFoundError when the profile is absent.
list_slugs
Section titled “list_slugs”def list_slugs() -> list[str]Every profile’s display handle, sorted.
merge_into
Section titled “merge_into”def merge_into(retired_ref: str | int, staging: Path, *, survivor_ref: str | int | None, survivor_rid: str, survivor_slug: str, build_missing_index: bool = True) -> IngestResultRetire one record into another in ONE write unit.
survivor_ref names the live record that receives the staged
content; the retired record is deleted. None is a convert: the
retired record itself survives, under survivor_rid. Either way the
staged directory’s rid must be survivor_rid. When the rid changes,
every other record about the retired rid moves to survivor_rid and
an alias keeps the retired rid resolving (successor_rid); a
retired slug keeps resolving to the survivor. Pre-commit hooks see
ctx.kind == "merge", ctx.profile_id (the survivor),
ctx.retired_profile_id (None for a convert), ctx.retired_rid
(None when the rid did not change) and ctx.retired_slug, so a
host can re-key its own rows in the same transaction. Only SQL
implements it; the others raise NotImplementedError. Raises
RetiredRidError when survivor_rid is itself retired.
profiles_about
Section titled “profiles_about”def profiles_about(rid: str) -> list[ProfileRef]Every record about the person rid, oldest first.
A retired rid resolves through successor_rid first. A directory
store returns zero or one entry (it refuses two directories with one
rid, DuplicateIdentityError); a published site returns what its
listing says.
put_document
Section titled “put_document”def put_document(slug: str, document: ProfileDocument) -> ResearcherProfileCreate-or-replace a profile’s canonical profile.jsonld.
Runs in one write unit (kind "create" if new, "edit" if it
exists), so hooks fire and dateModified is stamped. Derived
artifacts (sources/, .cache/) are left untouched: this writes
the document, not the bundle. A replace refuses a document with another
rid. Where slugs have aliases (SQL), a former slug replaces the record it
leads to when that record is about the document’s rid.
Returns the profile. Raises RetiredRidError for a retired rid
or slug.
resolve_slug
Section titled “resolve_slug”def resolve_slug(ref: str | int) -> strMap a ref to the live slug. Raises ProfileNotFoundError.
rids_with_email
Section titled “rids_with_email”def rids_with_email(email: str) -> list[str]rids of every profile whose top-level document email equals email.
Ignoring case and outer whitespace, sorted. Reads stored documents only:
never a host’s overlay or lens of someone else’s profile. A backend that
cannot enumerate documents (HTTP) raises NotImplementedError.
successor_rid
Section titled “successor_rid”def successor_rid(rid: str) -> Optional[str]The live rid a retired rid now resolves to, or None.
None for a rid nothing retired, and on every backend that cannot
merge. get, exists, resolve_slug and profiles_about
already follow it; this says whether they had to.
write_lookup_index
Section titled “write_lookup_index”def write_lookup_index() -> Optional[Path]Persist rid <-> slug so shell callers resolve without importing.
Nothing writes it implicitly. Returns None on a store with no
directory; such a store answers through profiles_about instead.
FilesystemProfileStore
Section titled “FilesystemProfileStore”class FilesystemProfileStore(root: str | os.PathLike, capacity: int = 32)Bases: _HookedStore, _AnalyticsAccessors
Profiles as directories under one root, with a small LRU over the
loaded ResearcherProfile objects.
Eviction only drops the reference; sqlite handles are GC’d with the index.
Attributes
Section titled “Attributes”Properties
Section titled “Properties”backend_spec
Section titled “backend_spec”@propertydef backend_spec -> str | NoneThe first readable backend name across the root, sqlite or flat.
First, not a consensus: a root whose profiles disagree is already broken (cosine across models is meaningless).
location
Section titled “location”@propertydef location -> str@propertydef root -> PathThe profiles root. Never None.
Methods
Section titled “Methods”alias_slugs
Section titled “alias_slugs”def alias_slugs() -> set[str]Always empty: a directory store keeps no aliases.
artifact_bytes
Section titled “artifact_bytes”def artifact_bytes(ref: str, content_url: str) -> bytesRead one artifact, refusing anything that escapes the profile dir.
The traversal check lives in the store so every caller inherits it.
centroid
Section titled “centroid”def centroid(ref: str) -> np.ndarrayThe profile’s centroid, from whichever index vector_index picked.
centroids_matrix
Section titled “centroids_matrix”def centroids_matrix() -> tuple[list[str], np.ndarray] | NoneNone: a directory holds no stacked matrix, only per-profile vectors.
<root>/.cache/centroids.npz is store.centroids’s own memo, not
an authoritative matrix.
commit_directory
Section titled “commit_directory”def commit_directory(slug: str, staging: Path, *, build_missing_index: bool = False) -> IngestResultValidate a staged directory loads, then atomically swap it live.
With build_missing_index and no .cache/embeddings.sqlite, a
best-effort build runs afterwards; failure leaves the profile committed
but unindexed rather than raising.
content_hash
Section titled “content_hash”def content_hash(ref: str) -> strRecomputed per call: this backend stores no digest column.
create
Section titled “create”def create(document: ProfileDocument, *, slug: str) -> ResearcherProfileCreate <root>/<slug>/ and persist document into it.
The write unit is not atomic here (ctx.atomic is False), so a
raising hook’s directory is removed explicitly. A directory corpus
holds one record per person, so a rid that already has one is refused.
create_bundle
Section titled “create_bundle”def create_bundle(document: ProfileDocument, *, slug: str, expertise: str | None = None, soul: str | None = None, artifacts: dict[str, str] | None = None) -> ResearcherProfileCreate <root>/<slug>/ with its document and every artifact.
Not atomic here; a failure part-way removes the whole directory.
delete
Section titled “delete”def delete(ref: str) -> strdocument_bytes
Section titled “document_bytes”def document_bytes(ref: str) -> bytesdef evict(ref: str) -> NoneDrop a cached profile so the next get reloads from disk.
Bumps the write generation too: the API layer calls it after writes this store never saw (an archive swapped in over its directory).
exists
Section titled “exists”def exists(ref: str) -> boolexport_directory
Section titled “export_directory”def export_directory(ref: str, dest: Path) -> PathCopy a profile directory to dest. Build state is not copied.
def get(ref: str) -> ResearcherProfilehas_vector_index
Section titled “has_vector_index”def has_vector_index(ref: str) -> boolWhether the profile has actual vectors, not just an empty DB.
held_artifacts
Section titled “held_artifacts”def held_artifacts(ref: str) -> dict[str, int | None]Manifest entries whose file exists under the profile directory, with its size.
list_slugs
Section titled “list_slugs”def list_slugs() -> list[str]merge_into
Section titled “merge_into”def merge_into(retired_ref: str, staging: Path, **kwargs) -> IngestResultpath_for
Section titled “path_for”def path_for(ref: str) -> PathThe absolute profile directory for a slug. Numpy-free.
profiles_about
Section titled “profiles_about”def profiles_about(rid: str) -> list[ProfileRef]The one directory whose profile.jsonld names rid, or nothing.
Backed by <root>/.cache/index.json (by_rid) or a scan; two
directories with one rid raise DuplicateIdentityError.
put_document
Section titled “put_document”def put_document(slug: str, document: ProfileDocument) -> ResearcherProfileSee put_document.
resolve_slug
Section titled “resolve_slug”def resolve_slug(ref: str) -> strMap a profile reference (a directory name or a rid) to a directory name.
Directory name first; a directory name can never be mistaken for a rid.
rids_with_email
Section titled “rids_with_email”def rids_with_email(email: str) -> list[str]rids whose document email equals email (case and outer space ignored).
Loads every profile, which is fine for a directory corpus.
successor_rid
Section titled “successor_rid”def successor_rid(rid: str) -> Optional[str]Always None: a directory store keeps no aliases (it cannot merge).
vector_index
Section titled “vector_index”def vector_index(ref: str) -> VectorIndexThe profile’s vectors: the build-local sqlite, else the served flat form.
sqlite first because it carries every chunk (including private ones) and text. Imports are deferred because both readers pull numpy.
write_lookup_index
Section titled “write_lookup_index”def write_lookup_index() -> Optional[Path]Persist rid <-> slug into <root>/.cache/index.json.
SqlProfileStore
Section titled “SqlProfileStore”class SqlProfileStore(engine_or_url: Any, *, one_profile_per_rid: bool = True)Bases: _HookedStore, _AnalyticsAccessors
A set of profiles living in SQL tables.
engine_or_url may be a SQLAlchemy Engine or a connection URL. The
store owns no per-profile state: every operation opens its own session, and
the SqlArtifactStorage behind a profile handed out by get opens
its own for each read and each write unit.
one_profile_per_rid (default True) holds at most one record per
person, like a directory corpus: a create about a rid that already has a
record is refused, and a rid is a ref. A host that keeps several records
about one person passes False; then a bare rid is not a ref here: the
host says which record it means (the API’s hooks.resolve_ref),
profiles_about lists them, and records are addressed by slug or
key.
Attributes
Section titled “Attributes”Properties
Section titled “Properties”backend_spec
Section titled “backend_spec”@propertydef backend_spec -> str | NoneThe embedding space this store’s vectors live in, or None.
One row off rp_chunk_vectors.
location
Section titled “location”@propertydef location -> strThe database URL. Display only.
@propertydef root -> NoneAlways None: this store is not a directory.
Methods
Section titled “Methods”alias_slugs
Section titled “alias_slugs”def alias_slugs() -> set[str]Every retired slug.
artifact_bytes
Section titled “artifact_bytes”def artifact_bytes(ref: Ref, content_url: str) -> bytesOne manifest artifact’s bytes.
No traversal check is needed: content_url is a row key, not a path.
A rendered collection is regenerated from the tables.
centroid
Section titled “centroid”def centroid(ref: Ref) -> np.ndarrayThe profile’s stored centroid: one row, already normalized.
Not derived from vector_index: the centroid covers the whole
index, private chunks included (one averaged vector is not invertible,
spec section 6), while the chunk rows are the public subset.
centroids_matrix
Section titled “centroids_matrix”def centroids_matrix() -> tuple[list[str], np.ndarray] | NoneThe whole roster’s centroids in one SELECT, or None.
commit_directory
Section titled “commit_directory”def commit_directory(slug: str, staging: Path, *, build_missing_index: bool = False) -> IngestResultMake a fully-staged profile directory live in this store.
One transaction, so a new record and its child rows commit together or
not at all. A live slug is a replace, and so is a former slug about
the same rid (see put); the result names the live slug.
A staged .cache/embeddings.sqlite is shredded into vector rows and
reported as indexed=True. Ingest is the only moment a vector can
enter this store, so build_missing_index builds one first
(best-effort; failure leaves the profile committed but unindexed).
content_hash
Section titled “content_hash”def content_hash(ref: Ref) -> strThe stored content_hash column, not a recompute.
create
Section titled “create”def create(document: ProfileDocument, *, slug: str) -> ResearcherProfileCreate a new profile from a validated document, in one write unit.
The row is inserted bare and then written through
ResearcherProfile.save_profile, so it takes the same path as
every other write, and a pre-commit hook can write a host’s own rows
about it (an ownership row) in the same transaction.
create_all
Section titled “create_all”def create_all() -> NoneCreate the rp_* tables if absent. Fresh-instance convenience.
create_bundle
Section titled “create_bundle”def create_bundle(document: ProfileDocument, *, slug: str, expertise: str | None = None, soul: str | None = None, artifacts: dict[str, str] | None = None) -> ResearcherProfileCreate a document and its authored artifacts in one transaction.
delete
Section titled “delete”def delete(ref: Ref) -> strRemove a profile, every child row and its slug aliases. Returns its rid.
document_bytes
Section titled “document_bytes”def document_bytes(ref: Ref) -> bytesThe canonical profile.jsonld bytes for ref.
Serialized on demand from the stored document.
researcher_profiles.schema.jsonld.canonical_dumps is a pure
function of the mapping, so these are the bytes save_profile
persisted.
def evict(ref: Ref) -> NoneBump the write generation. There are no cached rows to drop.
The analytics hold a view that could be pre-write; the bump drops it.
exists
Section titled “exists”def exists(ref: Ref) -> boolexport_directory
Section titled “export_directory”def export_directory(ref: Ref, dest: str | Path, *, with_build: bool = False) -> PathMaterialize a stored profile as a directory. Returns the directory.
profile.jsonld is canonical_dumps(document); every artifact body
is written to its own contentUrl; the works and grants collections
are re-rendered from rp_papers / rp_grants inside their stored
envelope, so the collection metadata (about, dateModified,
@context) survives the round trip.
Vectors are written as the published flat form, regenerated from the
rows (see _write_flat_export). No .cache/embeddings.sqlite
is reconstituted.
Build state is written only when with_build=True, into the build
root beside the directory, never inside it.
def get(ref: Ref, *, eager: bool = False) -> ResearcherProfileLoad one profile, backed by a SqlArtifactStorage over its rows.
has_vector_index
Section titled “has_vector_index”def has_vector_index(ref: Ref) -> boolWhether ref has chunk vectors. A LIMIT 1, never a fetch.
held_artifacts
Section titled “held_artifacts”def held_artifacts(ref: Ref) -> dict[str, int | None]One SELECT over the rp_artifacts rows that hold a body.
A rendered collection always has one (it is regenerated on the way
out); any other row needs a text or data body. The size is the
row’s size_bytes, else the stored body’s length.
import_directory
Section titled “import_directory”def import_directory(path: str | Path, *, include_binary: bool = False) -> intSugar for put(ResearcherProfile.from_files(path)). Returns the profile_id.
list_profiles
Section titled “list_profiles”def list_profiles() -> list[ProfileRow]Every profile row in the store, ordered by slug.
list_slugs
Section titled “list_slugs”def list_slugs() -> list[str]Every profile’s display handle, sorted. Selects one column only.
manifest_from_rows
Section titled “manifest_from_rows”def manifest_from_rows(ref: Ref) -> tuple[list[ArtifactRef], list[ArtifactRef]](hasPart, subjectOf) from rp_artifacts.
The row-based twin of build_manifest.
merge_into
Section titled “merge_into”def merge_into(retired_ref: Ref, staging: Path, *, survivor_ref: Optional[Ref], survivor_rid: str, survivor_slug: str, build_missing_index: bool = True) -> IngestResultRetire one record into another, in ONE write unit.
See merge_into. Hooks
run on the unit’s own session, so a host can re-key its rows in the
same transaction.
Raises ProfileWriteError when the retired and survivor
records are the same, the survivor is not about survivor_rid, a
convert keeps the rid, or the staged rid is not survivor_rid; RetiredRidError when
survivor_rid was itself retired or survivor_slug redirects to
another record; ProfileNotFoundError when a ref names no live
record; UploadError when the staged directory does not load.
profile_id_for
Section titled “profile_id_for”def profile_id_for(ref: Ref) -> intThe profile_id of the record ref names.
profiles_about
Section titled “profiles_about”def profiles_about(rid: str) -> list[ProfileRef]See profiles_about.
By profile_id (oldest first). Selects the identity columns only,
never a document.
def put(profile: ResearcherProfile, *, slug: Optional[str] = None, include_binary: bool = False) -> intWrite profile into the store, in one transaction. Returns its profile_id.
Takes any ResearcherProfile. Keyed on the slug: a live slug is a
replace (same record, same profile_id; the rid must match), and so
is a former slug (renamed or merged) whose record is about the same
rid, which keeps that record’s live slug. A new slug is a create. Every
child row is replaced rather than diffed, so the rows always equal
the source.
Artifacts come from the profile’s recorded manifest when it has one
(see _manifest_of), else from walking the directory.
include_binary opts into storing binary bodies; without it a binary
artifact keeps its manifest row and loses only its bytes. Vectors are
shredded on every put regardless.
Raises RetiredRidError when a create would reuse a rid a
merge retired, or a former slug’s record is about another rid;
ProfileWriteError when a live slug’s rid differs, or when a store
that holds one record per person already holds one about the rid.
That covers
commit_directory and import_directory.
put_document
Section titled “put_document”def put_document(slug: str, document: ProfileDocument) -> ResearcherProfileSee put_document.
A former slug (renamed or merged) whose record is about the document’s
rid replaces that record, which keeps its live slug; one about
another rid is a RetiredRidError.
refresh_vectors
Section titled “refresh_vectors”def refresh_vectors(ref: Ref) -> boolRebuild one profile’s vector rows from its current papers.
Exports the stored profile to a temp directory, builds the embedding
index there, shreds the result back into rp_chunk_vectors /
rp_profile_vectors, and cleans up. Best-effort: returns True
on success, False when the embedding stack is unavailable or the
build fails for any reason. Never raises.
rename
Section titled “rename”def rename(ref: Ref, new_slug: str) -> NoneChange a profile’s display handle. One update; no child row moves.
The old slug is kept in rp_slug_aliases, so links to it keep
resolving. The unique constraint on slug still applies, and a slug
that redirects to another record is refused.
resolve_slug
Section titled “resolve_slug”def resolve_slug(ref: Ref) -> strMap a ref (a slug, a rid, either retired, or a profile_id) to the live slug.
rids_with_email
Section titled “rids_with_email”def rids_with_email(email: str) -> list[str]Reads the rp_profiles.email projection.
session
Section titled “session”def session() -> Iterator[Session]successor_rid
Section titled “successor_rid”def successor_rid(rid: str) -> Optional[str]See successor_rid.
vector_index
Section titled “vector_index”def vector_index(ref: Ref) -> VectorIndexThe profile’s chunk-level index, built from its rows.
The rows are serialized into the published byte shapes and read back by
FlatEmbeddingIndex, the
single read implementation behind all backends.
write_lookup_index
Section titled “write_lookup_index”def write_lookup_index() -> NoneNone: no directory. profiles_about answers the same question.
Also exported from researcher_profiles.store:
IngestResult
Section titled “IngestResult”class IngestResult(slug: str, rid: str | None, name: str, level: str, indexed: bool, kept: dict[str, int] = dict(), spliced: int = 0, manifest_counts: dict[str, int] = dict(), mode: str = 'replace', profile_id: int | None = None)Outcome of committing one staged profile directory into a store.
Attributes
Section titled “Attributes”indexed(bool): Whether a built embedding index is present afterwards. AlwaysFalseon a store with no filesystem.kept(dict[str, int]) =field(default_factory=dict): Live files carried over rather than deleted, by class ({"fulltext": 53, "index": 1}); seeupload.WITHHELD_CLASSES. Filled in byingest_archive, not bycommit_directory.level(str)manifest_counts(dict[str, int]) =field(default_factory=dict):{role: count}over the manifest held after the commit. Filled in byingest_archive.mode(str) ='replace': Whichupload.PushModethe ingest ran under, and so what happened to the live files the archive did not carry.name(str)profile_id(int | None) =None: The store’s record key for the record written, on a backend that has one (SQL). Opaque to callers.rid(str | None)slug(str)spliced(int) =0: Manifest entries the server added back for kept files the incoming manifest did not list. Filled in byingest_archive; seeupload._splice_kept_into_manifest.
ProfileRef
Section titled “ProfileRef”class ProfileRef(profile_id: int | None, rid: str, slug: str)One record about a person, as ProfileStore.profiles_about lists it.
Attributes
Section titled “Attributes”profile_id(int | None): The store’s record key, opaque to callers, orNoneon a backend that has none (a directory).rid(str): Who the record is about.slug(str): Its display handle;store.get(slug)loads it.
Properties
Section titled “Properties”@propertydef ref -> int | strWhat to pass to store.get: the record key when there is one.
ProfileNotFoundError
Section titled “ProfileNotFoundError”class ProfileNotFoundErrorBases: ProfileError, KeyError
No profile in the store answers to a given ref (a slug, a rid, or a record key).
Both a ProfileError and a KeyError, so callers can catch it either
way.
UploadError
Section titled “UploadError”class UploadErrorBases: ValueError
A staged profile directory is malformed, unsafe, or does not load.
Defined here, not in api.upload, so backends need not import the HTTP
layer to raise it.
build_store
Section titled “build_store”def build_store(*, database_url: Optional[str] = None, profiles_dir: Optional[str] = None) -> ProfileStoreBuild the store an operator’s configuration describes.
Precedence, highest first, database over directory at each step:
database_url/profiles_dirpassed explicitly$RESEARCHER_PROFILES_DATABASE_URL/$RESEARCHER_PROFILES_ROOT
Deliberately no fallback to DEFAULT_CACHE_DIR: a server
pointed at a guessed directory is worse than one that refuses to start.
Raises ValueError when nothing is configured. Fails loudly on retired
env var names (researcher_profiles.env.check_retired_env_vars).
Identity resolution (researcher_profiles.resolve)
Section titled “Identity resolution (researcher_profiles.resolve)”The mint-vs-bind policy behind schema.mint_local_rid: given a rid or a
free-text name, decide whether it names an existing profile, an undecided
one (deferral), or nobody yet (mint). It runs against any ProfileStore, so
it works the same whether the caller is the HTTP route
(POST /identity/resolve) or a script holding a store directly.
resolve_person
Section titled “resolve_person”def resolve_person(store: Any, *, rid: Optional[str] = None, name: Optional[str] = None, affiliation: Optional[str] = None, create_new: bool = False) -> ResolveResultResolve a person descriptor to a rid, minting a stub on a true miss.
See the module docstring for the pipeline. affiliation is stamped on
any minted stub, selects among same-named candidates, and vetoes a match
it contradicts. create_new=True skips matching and mints a fresh
identity for name; calling it twice creates two people.
Raises ResolveError when the request itself is unusable. It
never raises for “no such person”; that is the mint path, not an
error.
ResolveResult
Section titled “ResolveResult”class ResolveResult(rid: Optional[str], created: bool, confidence: str, candidates: tuple[Candidate, ...] = ())The outcome of one resolve.
rid is None only in the deferred case, distinct from “no
match”, which mints. created says whether this call minted the
profile. confidence is "exact" for a rid identity, "high"
for a corroborated name match or a fresh mint, "low" for a
deferral. When a deferral’s candidates are all wrong, re-resolve with
create_new=True to mint a fresh identity.
Attributes
Section titled “Attributes”candidates(tuple[Candidate, …]) =field(default=())confidence(str)created(bool)rid(Optional[str])
Candidate
Section titled “Candidate”class Candidate(rid: str, name: str, affiliation: Optional[str] = None)One profile an undecidable name could mean.
Attributes
Section titled “Attributes”ResolveError
Section titled “ResolveError”class ResolveErrorBases: ValueError
The resolve request is unusable: no identity fields, a malformed
rid, an unknown local: rid, a placeholder name, or an ORCID miss
with no usable name to mint a stub from.
SQL profile store (researcher_profiles.store.db, researcher_profiles.store.sql)
Section titled “SQL profile store (researcher_profiles.store.db, researcher_profiles.store.sql)”Requires the [sql] extra. See
How to store profiles in a database.
This is a peer backing store. A profile in the rp_*
tables is a profile: it round-trips back to a byte-identical directory, and
SqlArtifactStorage reads and writes it through the same ArtifactStorage contract the
filesystem backend implements.
Tables (researcher_profiles.store.db)
Section titled “Tables (researcher_profiles.store.db)”ProfileRow
Section titled “ProfileRow”class ProfileRowBases: SQLModel
A whole profile document. Primary key: profile_id.
document is the record; every other column except
IDENTITY_COLUMNS is a projection written only by
from_document.
Attributes
Section titled “Attributes”affiliation(Optional[str]) =Noneaffiliation_id(Optional[str]) =Noneanchor(Optional[dict]) =Nonecareer(list)career_stage_as_of(Optional[str]) =Noneclinical_training_end_year(Optional[int]) =Nonecollaborators(list)conforms_to(Optional[str]) =Nonecontent_hash(str) ='': Seecontent_hash_for.critiques(list)current_rank(Optional[str]) =Nonedate_modified(Optional[str]) =None: The published vintage. Nullable and never defaulted: most profiles carry nodateModified, and inventing one would be a false claim.document(dict): The wholeprofile.jsonldpayload. The source of truth.email(Optional[str]) =Noneexpertise_cites_paper_ids(Optional[bool]) =Nonefield(Optional[str]) =Nonefirst_independent_appointment_year(Optional[int]) =Nonefirst_r01_equivalent_year(Optional[int]) =Nonehas_citation_graph(Optional[bool]) =Nonehas_embedding_index(Optional[bool]) =Noneidentifier(list)independence(Optional[str]) =Noneintellectual_lineage(list)interests(list)job_title(Optional[str]) =Nonelevel(str) ='full'license(Optional[str]) =None: Reuse terms for the published record (an IRI), when declared.methodological_commitments(list)name(str) =''not_interests(list)openalex_id(Optional[str]) =Noneorcid(Optional[str]) =None: Derived fromrid(NULL when the rid is local). A convenience for ORCID joins; join onrid.paper_stats(Optional[dict]) =Noneprofile_id(Optional[int]) =None: The record key, assigned by the store. Every child row joins on it.proof(list)provenance(str) ='': Who asserted this profile and on what basis. Non-null, as on disk.recurring_positions(list)research_interests(list): The typedrp:researchInterestsentries, as stored (every entry, not just the effective ones), so interests are searchable by concept code.research_outputs(list)rid(str): The subject: a canonical ORCID or alocal:id. Indexed, not unique (see the module docstring).same_as(list)scholar_url(Optional[str]) =Noneslug(str): Display handle. Unique within this store, never an identity claim.subfields(list)summary(Optional[str]) =Nonetenure_status(Optional[str]) =Noneterminal_degree_type(Optional[str]) =Noneterminal_degree_year(Optional[int]) =Nonetraining(list)url(Optional[str]) =Nonevisibility(str) ='public': Profile-level default privacy tier.
Class methods
Section titled “Class methods”from_document
Section titled “from_document”@classmethoddef from_document(meta: ProfileDocument, *, slug: str, document: Optional[dict] = None, soul: str = '') -> ProfileRowThe one writer of document and every projection derived from it.
Sets no identity column (IDENTITY_COLUMNS): the caller supplies
profile_id, rid and slug, or copies the result
onto a live row with take_content. slug only names the
profile in an error. meta supplies the projections; document is the
exact serialized payload to store, defaulting to meta’s canonical
dump. Pass it explicitly when ingesting a profile whose stored bytes
must survive verbatim. A nested node’s un-pruned null is content,
and re-dumping the model would rewrite it.
Registry-issued proofs (REGISTRY_ISSUED_PROOF_KINDS) are dropped
here, so no SQL write path can store one; content_hash covers the
stripped payload.
Methods
Section titled “Methods”take_content
Section titled “take_content”def take_content(fresh: ProfileRow) -> NoneCopy every column of fresh except IDENTITY_COLUMNS onto this row.
PaperRow
Section titled “PaperRow”class PaperRowBases: SQLModel
One work from sources/papers.jsonld.
Keyed by position (ordinal), never by paper_id: paper_id is a
generated citekey and is not unique within a profile in the real corpus.
record is the whole PaperRecord;
the other columns are a projection for querying (abstract and
summary are not projected). No build field appears here; build state
lives in rp_build_state.
Attributes
Section titled “Attributes”author_index(Optional[int]) =Noneauthor_position(Optional[str]) =Nonecited_by_count(Optional[int]) =Nonedoi(Optional[str]) =Nonefirst_author(Optional[str]) =Noneid(Optional[int]) =Noneis_corresponding(Optional[bool]) =Noneis_oa(Optional[bool]) =Nonejournal(Optional[str]) =Nonelast_author(Optional[str]) =Noneoa_status(Optional[str]) =Noneopen_access(Optional[bool]) =Noneopenalex_id(Optional[str]) =Noneordinal(int) =0: Position in the collection’shasPart. Re-renderingpapers.jsonldfrom these rows reproduces the original array order.paper_id(Optional[str]) =Nonepmcid(Optional[str]) =Nonepmid(Optional[str]) =Noneprofile_id(int)record(dict): The wholePaperRecord. The source of truth for this work.source(Optional[str]) =Nonetitle(str) =''total_authors(Optional[int]) =Nonetype(Optional[str]) =Noneurl(Optional[str]) =Nonevenue(Optional[str]) =Noneyear(Optional[int]) =None
Class methods
Section titled “Class methods”from_record
Section titled “from_record”@classmethoddef from_record(profile_id: int, paper: PaperRecord, *, ordinal: int) -> PaperRowBuild a row from a PaperRecord.
GrantRow
Section titled “GrantRow”class GrantRowBases: SQLModel
One award from sources/grants.jsonld.
A table of its own for cross-profile grant queries. The projection column
is grant_id because id is the autoincrement primary key.
Attributes
Section titled “Attributes”activity_code(Optional[str]) =Noneend(Optional[str]) =Nonefunder(Optional[str]) =Nonegrant_id(str):GrantRecord.id.id(Optional[int]) =Nonenumber(Optional[str]) =Noneordinal(int) =0profile_id(int)record(dict): The wholeGrantRecord. The source of truth for this award.role(Optional[str]) =Nonesource(Optional[str]) =Nonestart(Optional[str]) =Nonestatus(Optional[str]) =Nonetitle(str) =''url(Optional[str]) =None
Class methods
Section titled “Class methods”from_record
Section titled “from_record”@classmethoddef from_record(profile_id: int, grant: GrantRecord, *, ordinal: int) -> GrantRowBuild a row from a GrantRecord.
ExpertiseTopicRow
Section titled “ExpertiseTopicRow”class ExpertiseTopicRowBases: SQLModel
One expertise label belonging to a profile.
ordinal preserves the declared order: the labels are a curated, ordered
list in profile.jsonld, not a set.
Attributes
Section titled “Attributes”ArtifactRow
Section titled “ArtifactRow”class ArtifactRowBases: SQLModel
One file the profile contains. rp_artifacts is the manifest.
Keyed (profile_id, content_url), the manifest’s own address space.
The columns mirror ArtifactRef one for
one, except ArtifactRef.bytes is size_bytes here (bytes cannot
be a column name); from_part / to_part apply the mapping.
Bodies: text for markdown / JSON / JSON-LD / HTML, data for binary.
Binary bodies are opt-in (ProfileStore.put(..., include_binary=True))
because .cache/embeddings.sqlite can be tens of megabytes.
rendered=True marks an artifact whose bytes are regenerated from the
relational tables (sources/papers.jsonld, sources/grants.jsonld);
both bodies are NULL for those and envelope holds their collection node
minus hasPart, so a round-trip is byte-identical.
Attributes
Section titled “Attributes”content_url(str): The relative path, exactly asArtifactRef.contentUrl.data(Optional[bytes]) =Nonederived_from(list)encoding_format(Optional[str]) =Noneenvelope(Optional[dict]) =None: For a rendered collection: the node minushasPart(about,dateModified,@context,conformsTo, …).id(Optional[int]) =Nonemanifest_slot(str) =DEFAULT_MANIFEST_SLOT: Which manifest list this entry belongs to:hasPartorsubjectOf.name(Optional[str]) =Noneordinal(int) =0paper_id(Optional[str]) =Noneprofile_id(int)rendered(bool) =Falserole(Optional[str]) =Nonesha256(Optional[str]) =Nonesize_bytes(Optional[int]) =None:ArtifactRef.bytes; see the class docstring.text(Optional[str]) =Nonetype_(Optional[str]) =Nonevisibility(str) ='public'
Class methods
Section titled “Class methods”from_part
Section titled “from_part”@classmethoddef from_part(profile_id: int, part: ArtifactRef, *, manifest_slot: str = DEFAULT_MANIFEST_SLOT, ordinal: int = 0, text: Optional[str] = None, data: Optional[bytes] = None, rendered: bool = False, envelope: Optional[dict] = None) -> ArtifactRowBuild a row from one manifest entry plus its body.
Methods
Section titled “Methods”apply_part
Section titled “apply_part”def apply_part(part: ArtifactRef) -> NoneCopy a manifest entry’s fields onto this row, in place.
The document’s manifest is then regenerated from the rows, so the two cannot disagree.
to_part
Section titled “to_part”def to_part() -> ArtifactRefRebuild the manifest entry this row records.
ChunkVectorRow
Section titled “ChunkVectorRow”class ChunkVectorRowBases: SQLModel
One chunk’s embedding, as a queryable row. rp_chunk_vectors.
Shredded out of the per-profile .cache/embeddings.sqlite at ingest.
vector is row-major little-endian float32 (the bytes
researcher_profiles.embeddings._sqlite.serialize_vec writes) in a
portable LargeBinary column. Not sqlite-vec or pgvector, because the
tables must load unchanged on SQLite and Postgres. Cosine runs in numpy via
FlatEmbeddingIndex.
Rows are the public subset, the same one
write_flat_export publishes:
embeddings are partially invertible, so a chunk built from a private
source never reaches this table. text is not stored for the same reason.
The key mirrors the sqlite index’s own UNIQUE(source_type, source_id, chunk_index), scoped to the profile.
Attributes
Section titled “Attributes”backend_spec(str) =''char_count(Optional[int]) =Nonechunk_index(int) =0dim(int) =0id(Optional[int]) =Noneordinal(int) =0: Blob order:source_type, source_id, chunk_index, assigned at insert so a read can restore the published row order without re-sorting.profile_id(int)section(Optional[str]) =None:chunks.jsonmirror, so the flat form round-trips exactly.source_id(str)source_type(str)vector(bytes): Row-major little-endian float32,dimfloats.
ProfileVectorRow
Section titled “ProfileVectorRow”class ProfileVectorRowBases: SQLModel
One profile-level vector. rp_profile_vectors.
kind="centroid": the L2-normalized mean of the profile’s chunk vectors,
computed by researcher_profiles.embeddings.profile_vec._centroid_vec
so every backend reports the same number.
Separate from ChunkVectorRow so the whole roster’s centroids are one
SELECT of N rows. The relational form of
collection/embeddings/<backend>.bin.
Attributes
Section titled “Attributes”backend_spec(str) =''dim(int) =0kind(str) ='centroid'profile_id(int)vector(bytes): Little-endian float32,dimfloats. Already L2-normalized.
BuildStateRow
Section titled “BuildStateRow”class BuildStateRowBases: SQLModel
Build bookkeeping. Not part of the published record.
The SQL analogue of .build/<slug>/: DROP TABLE rp_build_state must
be safe, and every profile must still load afterwards. Never in the
manifest or an export bundle; SqlProfileStore.export_directory
writes it only with with_build=True. Stored whole because BuildState
makes no external schema promise.
Attributes
Section titled “Attributes”profile_id(int)schema_version(int) =0:BuildState’s own private counter. Build-local; never published.state(dict)
RidAliasRow
Section titled “RidAliasRow”class RidAliasRowBases: SQLModel
A retired person identity that now resolves to its successor rid.
A local: rid folded into an ORCID, say. Written only by
SqlProfileStore.merge_into, in the transaction that moves every
record about the retired rid onto the successor. Chains are one hop.
No foreign key: the retired rid is gone by design, and a rid is a person, not a row.
Attributes
Section titled “Attributes”SlugAliasRow
Section titled “SlugAliasRow”class SlugAliasRowBases: SQLModel
A retired handle that still redirects to a live record.
Written by SqlProfileStore.rename and
SqlProfileStore.merge_into. store.get(old_slug) follows it, and
a new profile cannot take it. Deleted with its record.
Attributes
Section titled “Attributes”create_all
Section titled “create_all”def create_all(engine: Engine) -> NoneCreate every rp_* table on engine if not present.
Fresh-instance convenience only. On a deployed Postgres, column evolution goes through numbered SQL migration files.
get_engine
Section titled “get_engine”def get_engine(url: Optional[str] = None, *, echo: bool = False) -> EngineReturn (building once per URL) the process-wide SQLAlchemy engine.
The URL resolves through resolve_database_url. SQLite URLs
get check_same_thread=False so one database is shared across a
threadpool.
reset_engine
Section titled “reset_engine”def reset_engine() -> NoneDrop the cached engine (tests reconfigure the database between cases).
get_session
Section titled “get_session”def get_session() -> Iterator[Session]Yield a Session bound to the shared engine.
A generator, usable as a FastAPI dependency.
content_hash_for
Section titled “content_hash_for”def content_hash_for(document: dict, soul: str) -> str"sha256:<hex>" over a profile’s canonical content.
Spans two artifacts, NUL-separated: the canonical profile document bytes and the SOUL text. The one definition, so every backend agrees.
SqlArtifactStorage (researcher_profiles.store.sql)
Section titled “SqlArtifactStorage (researcher_profiles.store.sql)”SqlArtifactStorage(engine_or_url) accepts a SQLAlchemy Engine or a URL. It
implements the whole ArtifactStorage contract
for one profile’s rows, and SqlProfileStore (above) implements the whole
ProfileStore protocol.
SqlArtifactStorage
Section titled “SqlArtifactStorage”class SqlArtifactStorage(store: SqlProfileStore, profile_id: Optional[int], *, slug: str, rid: str)Bases: ArtifactStorage
One profile’s rp_* rows.
The whole write unit (every save_*, the derived-state refresh, and
every pre-commit hook) commits or rolls back together. directory is
None; capabilities that need one refuse.
Attributes
Section titled “Attributes”EXPORT_REMEDY='export this profile with SqlProfileStore.export_directory (or `rp db pull`) and load the result with ResearcherProfile.from_files': What every directory-needing capability says on this backend.context_extra(dict[str, Any]): ExtraWriteContextfields for the next unit this storage opens (merge_intosetsretired_profile_id,retired_ridandretired_slug).pending_row(Optional[ProfileRow]): The barerp_profilesrow a create inserts when its first write unit opens, on that unit’s session (seenew_write_context).session(Optional[Session]): The session of the open write unit, orNone. Every writer goes through this; a second connection inside a unit would deadlock against rows the unit already holds.
Properties
Section titled “Properties”directory
Section titled “directory”@propertydef directory -> NoneAlways None: rows are not a directory.
@propertydef key -> strprofile_id
Section titled “profile_id”@propertydef profile_id -> Optional[int]The rp_profiles record key.
rid_hint
Section titled “rid_hint”@propertydef rid_hint -> strThe subject’s rid, known without reading a document.
Lets a write unit name the profile while its document is replaced.
@propertydef slug -> strMethods
Section titled “Methods”artifact_bytes
Section titled “artifact_bytes”def artifact_bytes(content_url: str) -> Optional[bytes]artifact_text
Section titled “artifact_text”def artifact_text(content_url: str) -> Optional[str]One artifact’s stored text body, or None.
A rendered collection has no stored body; it is regenerated from
rp_papers / rp_grants on the way out.
build_manifest
Section titled “build_manifest”def build_manifest() -> tuple[list[ArtifactRef], list[ArtifactRef]]Generate the manifest from rp_artifacts.
collection_envelope
Section titled “collection_envelope”def collection_envelope(content_url: str) -> dictA collection node minus hasPart, so a re-render is faithful.
commit
Section titled “commit”def commit(ctx: WriteContext) -> Nonecontent_hash
Section titled “content_hash”def content_hash() -> strThe stored content_hash column, refreshed by refresh_derived.
delete_summary
Section titled “delete_summary”def delete_summary(paper_id: str) -> Nonedef index(profile: ResearcherProfile) -> NoLocalIndexNo directory, so no sqlite index handle: every operation refuses.
load_build_state
Section titled “load_build_state”def load_build_state() -> BuildStateBuild state from rp_build_state; an empty state when absent.
A missing table counts as absent, so dropping rp_build_state is
safe.
load_citations
Section titled “load_citations”def load_citations() -> Anyload_document
Section titled “load_document”def load_document() -> ProfileDocumentload_expertise
Section titled “load_expertise”def load_expertise() -> strload_grants
Section titled “load_grants”def load_grants() -> list[GrantRecord]load_papers
Section titled “load_papers”def load_papers() -> list[PaperRecord]load_persisted_document
Section titled “load_persisted_document”def load_persisted_document() -> dict[str, Any]The document currently in the store; {} when absent.
Comparison basis for dateModified, not a load path: errors are
swallowed.
Empty hasPart/subjectOf slots are dropped to match the model
dump, which omits them. Otherwise every identical re-push would look
like an edit and break If-Match sync.
load_soul
Section titled “load_soul”def load_soul() -> strload_summaries
Section titled “load_summaries”def load_summaries() -> Mapping[str, str]locate
Section titled “locate”def locate(*parts: str) -> strDisplay-only locator: a db:// reference naming the row.
new_write_context
Section titled “new_write_context”def new_write_context(profile: ResearcherProfile, kind: str) -> WriteContextOpen a transaction and hand it to the unit.
atomic=True: everything inside this unit, hooks included, commits
or rolls back together. A profile being created inserts its bare row
(pending_row) here, on the unit’s session, so the context
already carries its profile_id; a database refusal (a second self
profile for the rid or the owner) is a ProfileWriteError.
refresh_derived
Section titled “refresh_derived”def refresh_derived(ctx: WriteContext) -> NoneRecompute content_hash inside the unit, before the hooks run.
The write unit’s job, not save_document’s, because a soul-only
write changes the digest too.
rollback
Section titled “rollback”def rollback(ctx: WriteContext) -> NoneA real rollback: nothing this unit wrote survives.
save_build_state
Section titled “save_build_state”def save_build_state(state: BuildState) -> Nonesave_citations
Section titled “save_citations”def save_citations(data: Any) -> NoneWrite the citation graph; None DELETES the artifact.
None means absent to load_citations, so it is not stored.
save_document
Section titled “save_document”def save_document(data: Mapping[str, Any]) -> NonePersist the document, and keep it consistent with rp_artifacts.
In order (the order is the contract):
- The incoming manifest is applied onto the artifact rows.
hasPart/subjectOfare regenerated from the rows, so the document and the rows cannot disagree.- The result is written through
ProfileRow.from_document.
data arrives already stamped, canonicalized and re-validated.
save_expertise
Section titled “save_expertise”def save_expertise(text: str) -> Nonesave_grants
Section titled “save_grants”def save_grants(grants: list[GrantRecord]) -> Nonesave_papers
Section titled “save_papers”def save_papers(papers: list[PaperRecord]) -> Nonesave_soul
Section titled “save_soul”def save_soul(text: str) -> Nonesave_summary
Section titled “save_summary”def save_summary(paper_id: str, text: str) -> Nonewrite_artifact
Section titled “write_artifact”def write_artifact(content_url: str, text: Optional[str], *, role: str, name: str, encoding_format: str = 'text/plain', manifest_slot: str = DEFAULT_MANIFEST_SLOT, paper_id: Optional[str] = None) -> NoneUpsert one artifact row, creating its manifest entry when new.
The descriptive arguments become the manifest row, since rows cannot be
rediscovered by walking. paper_id is for summary rows.
rp db init | push | pull | list | rm. The database URL resolves
--database-url → $RP_DATABASE_URL, with no built-in default; a missing URL
exits 2.