Skip to content

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.

SurfaceImportExtra
Schema models, ResearcherProfile, OpenAlex parserresearcher_profilescore
resolve_personresearcher_profiles.resolvecore
prof.index (SqliteEmbeddingIndex), store.match / .centroids / .indexesresearcher_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), FilesystemProfileStoreresearcher_profiles.storecore
create_appresearcher_profiles.api.app[api]
ArtifactStorage (ABC), DirectoryArtifactStorageresearcher_profiles.profile.storagecore
SqlProfileStore, SqlArtifactStorage, .from_db(), the rp_* tablesresearcher_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.


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.

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.

@property
def affiliation -> str | None

@property
def build_state -> BuildState

Build bookkeeping from .build/<slug>/meta/build_state.json.

Not part of the published record. Empty when the sidecar is absent.

@property
def citations -> Any

@property
def cite -> Any

prof.cite: get, many, verify, export.

@property
def clinical_expertise -> str

personality/clinical_expertise.md; "" when absent.

@property
def coverage -> Any

prof.coverage: get, staleness, recent_work, last_updated.

@property
def directory -> Path | None

The 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.

@property
def edit -> Any

Owner metadata, SOUL, and visibility changes.

@property
def expertise -> str

@property
def field -> str | None

@property
def grants -> list[GrantRecord]

Grant records from sources/grants.jsonld; empty when absent.

@property
def has_persona -> bool

Whether 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.

@property
def index -> Any

prof.index: build, search, search_similar, embedding.

@property
def level -> str

Profile depth tier: "lite" / "full" / "deep".

Read from metadata.level only; there is no fallback.

@property
def license -> str | None

Reuse terms for the published record.

@property
def metadata -> ProfileDocument

The parsed profile.jsonld document.

@property
def name -> str

@property
def orcid -> str | None

Derived from rid; None for a locally-minted identity.

@property
def papers -> list[PaperRecord]

@property
def persona -> Any

prof.persona: ask, review, innovate, riff, chat.

@property
def profile_id -> int | None

The 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.

@property
def provenance -> str

Who asserted this profile and on what basis.

@property
def rid -> str

The 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.

@property
def 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.

@property
def slug -> str

Display 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.

@property
def soul -> str

@property
def storage -> ArtifactStorage

The storage backend holding every artifact of this profile.

@property
def summaries -> Mapping[str, str]

@property
def summary -> str | None

@property
def topics -> Any

prof.topics: get, relevance.

@property
def trials -> list[TrialRecord]

Clinical trials from sources/trials.jsonld; empty when absent.

The optional clinical extension; empty is normal.

@classmethod
def from_api(url: str, *, slug: str | None = None, token: str | None = None, timeout: float = 60.0, client: Any = None) -> ResearcherProfile

Construct 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.

@classmethod
def from_db(engine_or_url: Any, ref: str, *, eager: bool = False) -> ResearcherProfile

Construct 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")

@classmethod
def from_files(path: str | os.PathLike, *, eager: bool = False, validate: bool = False) -> ResearcherProfile

Construct 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.

@classmethod
def from_url(url: str, *, eager: bool = False, timeout: float = 30.0, client: Any = None) -> ResearcherProfile

Construct 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.

@classmethod
def 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.

def add_post_commit_hook(hook: WriteHook) -> None

Register 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.

def add_pre_commit_hook(hook: WriteHook) -> None

Register 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.

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.

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() -> None

Release 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.

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.

def delete_summary(paper_id: str) -> None

Remove one paper summary body; absent is not an error.

def locate(*parts: str) -> str

A 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.

def manifest() -> list[ArtifactRef]

The manifest as recorded in profile.jsonld (hasPart + subjectOf).

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.

def refresh() -> None

Drop cached reads so the next access re-fetches.

Also asks the backend to drop its own caches.

def remove_seed_paper(seed_id: str) -> bool

Drop one seed by SeedPaperRecord.seed_id. False when absent.

def require_directory(what: str) -> Path

The backing directory, or raise CapabilityUnavailableError.

def rid_or_empty() -> str

The rid, or "" when no document is readable yet.

A profile being created has no readable document yet.

def save_build_state(state: BuildState | None = None) -> None

Persist build state; None persists what is in memory.

Build state is not published content; a read-only backend has none.

def save_citations(data: Any = UNSET) -> None

Persist the citation graph; None deletes it.

Omitting data persists what is in memory.

def save_clinical_expertise(text: str) -> None

Persist personality/clinical_expertise.md.

def save_expertise(text: str | None = None) -> None

Persist the expertise narrative; None persists what is in memory.

def save_grants(grants: list[GrantRecord] | None = None) -> None

Persist the grant records; None persists what is in memory.

def save_papers(papers: list[PaperRecord] | None = None) -> None

Persist the paper corpus; None persists what is in memory.

def save_profile(doc: ProfileDocument | None = None) -> ProfileDocument

Validate, 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.

def save_seed_papers(seeds: list[SeedPaperRecord] | None = None) -> None

Persist 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.

def save_soul(text: str | None = None) -> None

Persist the SOUL narrative; None persists what is in memory.

def save_summary(paper_id: str, text: str) -> None

Persist one paper summary body.

def save_trials(trials: list[TrialRecord] | None = None) -> None

Persist the clinical trials; None persists what is in memory.

def set_paper_contaminated(paper_id: str, contaminated: bool) -> bool

Flag a paper contaminated. Returns True if the paper exists.

Contamination is build state; the published record is untouched.

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.

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.

def validate() -> Any

Validate 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.

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.

class EditManager(profile: ResearcherProfile)

Owner-edit policy and mutation surface for one profile.

def add_work(record: PaperRecord | dict[str, Any]) -> PaperRecord

Add one work, or replace the one already carrying its paper_id.

def patch_metadata(patch: dict[str, Any]) -> ProfileDocument

Patch owner-editable metadata fields and persist the document.

def patch_work(paper_id: str, patch: dict[str, Any]) -> PaperRecord

Patch 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.

def remove_work(paper_id: str) -> None

Remove one work from sources/papers.jsonld.

def set_soul(soul: str) -> None

Replace the free-form SOUL/persona narrative.

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.

class ProfileError

Bases: Exception

Base exception for researcher_profiles.

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.

  • location
  • original

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.

  • location
  • original

class CapabilityUnavailableError

Bases: ProfileError, NotImplementedError

A capability needs something this backend does not have.

Both a ProfileError and a NotImplementedError, so either except clause catches it.

class ProfileValidationError(report: Any)

Bases: ProfileError

Raised when from_files(..., validate=True) produces a non-ok report.

  • report

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.

  • slug

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.

def render_export_text(profile: ResearcherProfile, options: ExportOptions | None = None, *, papers: Sequence[PaperRecord] | None = None) -> str

Render 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.

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.

def build_export_bundle(profile: ResearcherProfile, options: ExportOptions | None = None, *, papers: Sequence[PaperRecord] | None = None, now: str | None = None) -> ProfileExportBundle

Render 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.

def explore_url(profile_url: str | None, *, explore_base: str | None = None) -> str | None

The 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.

def export_content_hash(payload: Mapping[str, Any]) -> str

"sha256:<64 hex>" over payload in canonical JSON form.

Reproducible across machines and Python versions.

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.

class ExportOptions

Bases: BaseModel

Every knob the export surface has. New knobs are fields with defaults.

  • 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).

class ExportPaperRef

Bases: BaseModel

One paper whose prose is inside the rendered text.

  • 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)

class ProfileExportBundle

Bases: 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.

  • 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-opening profile.jsonld. This package owns profile reading.
  • text (str)
  • text_chars (int)
  • visibility (str)

class ExportError

Bases: ProfileError

Base error for the knowledge-base export surface.

class ExportVisibilityError

Bases: 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.

class DuplicateIdentityError

Bases: 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)”

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.

@property
def backend

The 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.

@property
def cache_path -> Optional[Path]

<root>/.cache/centroids.npz, or None without a root.

@property
def matrix -> np.ndarray

L2-normalized centroids, one row per profile, slug-ordered.

def embed_query(text: str) -> np.ndarray

text as a unit vector in the same space as matrix.

def invalidate() -> None

Drop the in-memory matrix and unlink the .npz.

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.

class MatchManager(store: VectorStore, rostered: _RosterCache)

store.match: ranking, diversification, topics, clustering.

@property
def topics_cache_path -> Optional[Path]

<root>/.cache/topics.json (a view of topics), or None without a root.

def cluster(k: int | None = None) -> dict[str, list[ResearcherProfile]]

Group profiles by centroid; each group is named for its exemplar.

def diversify(matches: list[Match], k: int = 5, lambda_: float = 0.5) -> list[Match]

Re-order by maximal marginal relevance: relevant but not redundant.

def invalidate() -> None

Unlink 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.

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.

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.

def coverage() -> list[dict[str, Any]]

Per-profile index / topic / calibration status, slug-ordered.

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.

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.

  • base_url

@property
def directory -> None

@property
def key -> str

@property
def rid_hint -> None

@property
def slug -> str

def artifact_bytes(content_url: str) -> Optional[bytes]

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.

def build_manifest() -> tuple[list[ArtifactRef], list[ArtifactRef]]

The manifest the server holds (GET .../files); there is nothing to walk.

def close() -> None

def collection_envelope(content_url: str) -> dict

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) -> _RemoteIndex

search over HTTP; everything else needs the local sqlite handle.

def load_citations() -> Any

def load_document() -> ProfileDocument

The 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.

def load_expertise() -> str

def load_grants() -> list[GrantRecord]

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.

def load_soul() -> str

def load_summaries() -> Mapping[str, str]

def locate(*parts: str) -> str

Display-only locator: the API URL naming this artifact.

def persona(profile: ResearcherProfile) -> _RemotePersona

HTTP-backed persona: the server owns model, corpus and refusals.

def refresh() -> None

Drop cached immutable resources so the next access refetches.

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.

  • base_url

@property
def directory -> None

@property
def key -> str

@property
def rid_hint -> None

@property
def slug -> str

def artifact_bytes(content_url: str) -> Optional[bytes]

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.

def build_manifest() -> tuple[list[ArtifactRef], list[ArtifactRef]]

The recorded manifest: a static host has no directory to walk.

def close() -> None

def collection_envelope(content_url: str) -> dict

def content_hash() -> str

"sha256:<hex>" over the fetched document + soul.

def load_citations() -> Any

def load_document() -> ProfileDocument

def load_expertise() -> str

def load_grants() -> list[GrantRecord]

def load_papers() -> list[PaperRecord]

def load_soul() -> str

def load_summaries() -> Mapping[str, str]

def locate(*parts: str) -> str

Display-only locator: the published URL naming this artifact.

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{...}}).

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) -> ResolveResult

Resolve 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).


researcher_profiles.openalex does pure record parsing, with no HTTP. It is available in core.

def parse_work(raw: dict) -> PaperRecord | None

Parse 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).

def decode_abstract_inverted_index(idx: dict[str, list[int]] | None) -> str

Reconstruct 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.

def to_work_dict(raw: dict) -> dict

The {title, abstract, year, doi, openalex_id, coauthors} lite shape.

def to_normalized_dict(raw: dict) -> dict

Same as to_work_dict but with authors in place of coauthors.


researcher_profiles.embeddings is a per-profile vector store. It requires the [vectors,st] extras.

class SqliteEmbeddingIndex(profile_dir: str | os.PathLike, *, profile_document: Any = None, corpus_only: bool = False)

sqlite-vec index for a single profile.

  • SCHEMA_VERSION = INDEX_SCHEMA_VERSION
  • corpus_only: Leave out summaries of papers not in sources/papers.jsonld (see enumerate_source_chunks).
  • db_path
  • index_dir
  • profile_dir

@property
def backend_spec -> str

The embedding model this index was built with; "" when unbuilt.

def build_index(*, force: bool = False, backend: EmbeddingBackend | str | None = None) -> IndexReport

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.

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.

def exists() -> bool

True when embeddings.sqlite is present.

def index_meta() -> dict[str, str]

index_meta as a dict; {} when there is no readable index.

def mtime() -> float | None

The index file’s mtime, or None when it is absent.

def search(query: str, k: int = 5, filter: dict | None = None) -> list[SearchHit]

def search_similar(source_type: str, source_id: str, chunk_index: int = 0, k: int = 5) -> list[SearchHit]

def stats() -> IndexStats

exists + counts + index_meta + mtime.

def build_index(profile, *, force: bool = False, backend=None, corpus_only: bool = False) -> IndexReport

Build (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.

class IndexManager(profile)

prof.index: the per-profile embedding index.

Needs a local directory: the index is a .cache/embeddings.sqlite handle.

def build(*, force: bool = False, backend=None) -> IndexReport

Build (or refresh) this profile’s embedding index.

def embedding(kind: str = 'centroid')

The profile-level vector: centroid / summary / expertise.

def search(query: str, k: int = 5, filter: dict | None = None)

Semantic search over this profile’s chunks.

def search_similar(source_type: str, source_id: str, chunk_index: int = 0, k: int = 5)

Chunks nearest to one this profile already holds.

class SearchHit(text: str, source_type: str, source_id: str, chunk_index: int, section: str | None, cosine: float, score: float, meta: dict = dict())
  • chunk_index (int)
  • cosine (float)
  • meta (dict) = field(default_factory=dict)
  • score (float)
  • section (str | None)
  • source_id (str)
  • source_type (str)
  • text (str)

def to_dict() -> dict[str, Any]

class IndexReport(added: int = 0, updated: int = 0, skipped: int = 0, removed: int = 0, backend_name: str = '', duration_s: float = 0.0)
  • added (int) = 0
  • backend_name (str) = ''
  • duration_s (float) = 0.0
  • removed (int) = 0
  • skipped (int) = 0
  • updated (int) = 0

def to_dict() -> dict[str, Any]

class MissingEmbeddingBackendError

Bases: RuntimeError

Raised when the chosen embedding backend is not importable / usable.

class IndexBackendMismatchError

Bases: RuntimeError

Raised when the existing index was built with a different backend.

Pass force=True to drop and recreate.

class IndexNotBuiltError

Bases: RuntimeError

Raised when an index is read but none exists yet at that path.


researcher_profiles.models.results holds the return types for capability methods.

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.

  • citations (list[CitationRef])
  • grounded (bool) = True
  • model (str)
  • raw (Any)
  • refusal_reason (Optional[str]) = None
  • refused (bool) = False
  • request_id (Optional[str]) = None
  • text (str)
  • usage (dict)

class Idea(hypothesis: str, approach: str, rationale: str, related_works: list[str])

One proposed research direction from ResearcherProfile.innovate.

  • approach (str)
  • hypothesis (str)
  • rationale (str)
  • related_works (list[str])

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.

  • angle (str)
  • related_work (Optional[str]) = None
  • text (str)

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.

  • paper_id (str)
  • relevance (float | None) = None
  • span (str | None) = None

def to_dict() -> dict

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.

  • abstract (str | None) = None
  • authors (list[str]) = field(default_factory=list)
  • doi (str | None) = None
  • paper_id (str)
  • pdf_url (str | None) = None
  • title (str)
  • url (str | None) = None
  • venue (str | None) = None
  • year (int | None) = None

def to_bibtex() -> str

def to_csl() -> str

def to_dict() -> dict

class Match(profile: Any, score: float, evidence: MatchEvidence, explanation: Optional[str] = None)

One profile match against a query, with score, evidence, and lazy explanation.

  • evidence (MatchEvidence)
  • explanation (Optional[str]) = None
  • profile (Any)
  • score (float)

def explain(query: str = '', *, client: Any = None) -> str

Produce a short LLM-backed explanation of why this profile matched.

Cached on first call. Subsequent calls return the cached value.

def to_dict() -> dict[str, Any]

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.

  • 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])

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.

  • chunk_ids (list[int]) = field(default_factory=list)
  • label (str)
  • paper_ids (list[str]) = field(default_factory=list)
  • weight (float)

def to_dict() -> dict[str, Any]

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.

  • 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]])

def to_dict() -> dict

class GenerativeParseError(message: str, raw_text: str = '')

Bases: ProfileError

Raised when the LLM repeatedly fails to return valid JSON.

  • raw_text

PersonaUnavailableError is documented under ResearcherProfile exceptions above.


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.

class ProfileStore

Bases: 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.

@property
def location -> str

A human-readable locator for this store. Display only.

A directory path, a database URL. Never parse it or join to it.

@property
def 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 in api.deps, which turns a None root into a 503 for graph-shaped features, or materializes a temp directory.
  • The optional write-through memos <root>/.cache/centroids.npz and <root>/.cache/topics.json, which a store without a directory simply does without (analytics.centroids, analytics.match, and analytics.roster, which re-exposes the value as _Roster.root for those two).
  • Staging and archiving paths that want to os.rename on the store’s own filesystem (api.upload, api.routers.push); each already has a scratch-space fallback for None.
  • Invalidating those same store-wide caches after a write (api.service.Service.invalidate). They sit outside ArtifactStorage, so nothing else can drop them; a None root 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.

def add_post_commit_hook(hook: WriteHook) -> None

Register 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.

def add_pre_commit_hook(hook: WriteHook) -> None

Register 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.

def alias_slugs() -> set[str]

Slugs of retired profiles. They stay reserved: allocate around them.

def artifact_bytes(ref: str, content_url: str) -> bytes

One 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.

def commit_directory(slug: str, staging: Path, *, build_missing_index: bool = False) -> IngestResult

Make 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.

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.

def create(document: ProfileDocument, *, slug: str) -> ResearcherProfile

Create 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.

def create_bundle(document: ProfileDocument, *, slug: str, expertise: Optional[str] = None, soul: Optional[str] = None, artifacts: Optional[dict[str, str]] = None) -> ResearcherProfile

Create 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.

def delete(ref: str | int) -> str

Remove a profile and everything belonging to it. Returns its rid.

def document_bytes(ref: str) -> bytes

The 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) -> None

Drop 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.

def exists(ref: str | int) -> bool

Whether ref names a profile in this store.

def export_directory(ref: str, dest: Path) -> Path

Materialize a profile as a directory at dest. Returns dest.

The inverse of commit_directory.

def get(ref: str | int) -> ResearcherProfile

Load a profile. Raises ProfileNotFoundError.

The returned profile carries every hook registered through add_pre_commit_hook, so any write through it fires them.

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.

def list_slugs() -> list[str]

Every profile’s display handle, sorted.

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) -> IngestResult

Retire 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.

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.

def put_document(slug: str, document: ProfileDocument) -> ResearcherProfile

Create-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.

def resolve_slug(ref: str | int) -> str

Map a ref to the live slug. Raises ProfileNotFoundError.

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.

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.

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.

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.

  • capacity

@property
def backend_spec -> str | None

The 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).

@property
def location -> str

@property
def root -> Path

The profiles root. Never None.

def alias_slugs() -> set[str]

Always empty: a directory store keeps no aliases.

def artifact_bytes(ref: str, content_url: str) -> bytes

Read one artifact, refusing anything that escapes the profile dir.

The traversal check lives in the store so every caller inherits it.

def centroid(ref: str) -> np.ndarray

The profile’s centroid, from whichever index vector_index picked.

def centroids_matrix() -> tuple[list[str], np.ndarray] | None

None: a directory holds no stacked matrix, only per-profile vectors.

<root>/.cache/centroids.npz is store.centroids’s own memo, not an authoritative matrix.

def commit_directory(slug: str, staging: Path, *, build_missing_index: bool = False) -> IngestResult

Validate 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.

def content_hash(ref: str) -> str

Recomputed per call: this backend stores no digest column.

def create(document: ProfileDocument, *, slug: str) -> ResearcherProfile

Create <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.

def create_bundle(document: ProfileDocument, *, slug: str, expertise: str | None = None, soul: str | None = None, artifacts: dict[str, str] | None = None) -> ResearcherProfile

Create <root>/<slug>/ with its document and every artifact.

Not atomic here; a failure part-way removes the whole directory.

def delete(ref: str) -> str

def document_bytes(ref: str) -> bytes

def evict(ref: str) -> None

Drop 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).

def exists(ref: str) -> bool

def export_directory(ref: str, dest: Path) -> Path

Copy a profile directory to dest. Build state is not copied.

def get(ref: str) -> ResearcherProfile

def has_vector_index(ref: str) -> bool

Whether the profile has actual vectors, not just an empty DB.

def held_artifacts(ref: str) -> dict[str, int | None]

Manifest entries whose file exists under the profile directory, with its size.

def list_slugs() -> list[str]

def merge_into(retired_ref: str, staging: Path, **kwargs) -> IngestResult

def path_for(ref: str) -> Path

The absolute profile directory for a slug. Numpy-free.

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.

def put_document(slug: str, document: ProfileDocument) -> ResearcherProfile

See put_document.

def resolve_slug(ref: str) -> str

Map 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.

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.

def successor_rid(rid: str) -> Optional[str]

Always None: a directory store keeps no aliases (it cannot merge).

def vector_index(ref: str) -> VectorIndex

The 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.

def write_lookup_index() -> Optional[Path]

Persist rid <-> slug into <root>/.cache/index.json.

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.

  • engine
  • one_profile_per_rid
  • url

@property
def backend_spec -> str | None

The embedding space this store’s vectors live in, or None.

One row off rp_chunk_vectors.

@property
def location -> str

The database URL. Display only.

@property
def root -> None

Always None: this store is not a directory.

def alias_slugs() -> set[str]

Every retired slug.

def artifact_bytes(ref: Ref, content_url: str) -> bytes

One 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.

def centroid(ref: Ref) -> np.ndarray

The 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.

def centroids_matrix() -> tuple[list[str], np.ndarray] | None

The whole roster’s centroids in one SELECT, or None.

def commit_directory(slug: str, staging: Path, *, build_missing_index: bool = False) -> IngestResult

Make 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).

def content_hash(ref: Ref) -> str

The stored content_hash column, not a recompute.

def create(document: ProfileDocument, *, slug: str) -> ResearcherProfile

Create 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.

def create_all() -> None

Create the rp_* tables if absent. Fresh-instance convenience.

def create_bundle(document: ProfileDocument, *, slug: str, expertise: str | None = None, soul: str | None = None, artifacts: dict[str, str] | None = None) -> ResearcherProfile

Create a document and its authored artifacts in one transaction.

def delete(ref: Ref) -> str

Remove a profile, every child row and its slug aliases. Returns its rid.

def document_bytes(ref: Ref) -> bytes

The 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) -> None

Bump the write generation. There are no cached rows to drop.

The analytics hold a view that could be pre-write; the bump drops it.

def exists(ref: Ref) -> bool

def export_directory(ref: Ref, dest: str | Path, *, with_build: bool = False) -> Path

Materialize 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) -> ResearcherProfile

Load one profile, backed by a SqlArtifactStorage over its rows.

def has_vector_index(ref: Ref) -> bool

Whether ref has chunk vectors. A LIMIT 1, never a fetch.

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.

def import_directory(path: str | Path, *, include_binary: bool = False) -> int

Sugar for put(ResearcherProfile.from_files(path)). Returns the profile_id.

def list_profiles() -> list[ProfileRow]

Every profile row in the store, ordered by slug.

def list_slugs() -> list[str]

Every profile’s display handle, sorted. Selects one column only.

def manifest_from_rows(ref: Ref) -> tuple[list[ArtifactRef], list[ArtifactRef]]

(hasPart, subjectOf) from rp_artifacts.

The row-based twin of build_manifest.

def merge_into(retired_ref: Ref, staging: Path, *, survivor_ref: Optional[Ref], survivor_rid: str, survivor_slug: str, build_missing_index: bool = True) -> IngestResult

Retire 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.

def profile_id_for(ref: Ref) -> int

The profile_id of the record ref names.

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) -> int

Write 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.

def put_document(slug: str, document: ProfileDocument) -> ResearcherProfile

See 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.

def refresh_vectors(ref: Ref) -> bool

Rebuild 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.

def rename(ref: Ref, new_slug: str) -> None

Change 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.

def resolve_slug(ref: Ref) -> str

Map a ref (a slug, a rid, either retired, or a profile_id) to the live slug.

def rids_with_email(email: str) -> list[str]

Reads the rp_profiles.email projection.

def session() -> Iterator[Session]

def successor_rid(rid: str) -> Optional[str]

See successor_rid.

def vector_index(ref: Ref) -> VectorIndex

The 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.

def write_lookup_index() -> None

None: no directory. profiles_about answers the same question.

Also exported from researcher_profiles.store:

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.

  • indexed (bool): Whether a built embedding index is present afterwards. Always False on 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}); see upload.WITHHELD_CLASSES. Filled in by ingest_archive, not by commit_directory.
  • level (str)
  • manifest_counts (dict[str, int]) = field(default_factory=dict): {role: count} over the manifest held after the commit. Filled in by ingest_archive.
  • mode (str) = 'replace': Which upload.PushMode the 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 by ingest_archive; see upload._splice_kept_into_manifest.

class ProfileRef(profile_id: int | None, rid: str, slug: str)

One record about a person, as ProfileStore.profiles_about lists it.

  • profile_id (int | None): The store’s record key, opaque to callers, or None on a backend that has none (a directory).
  • rid (str): Who the record is about.
  • slug (str): Its display handle; store.get(slug) loads it.

@property
def ref -> int | str

What to pass to store.get: the record key when there is one.

class ProfileNotFoundError

Bases: 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.

class UploadError

Bases: 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.

def build_store(*, database_url: Optional[str] = None, profiles_dir: Optional[str] = None) -> ProfileStore

Build the store an operator’s configuration describes.

Precedence, highest first, database over directory at each step:

  1. database_url / profiles_dir passed explicitly
  2. $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.

def resolve_person(store: Any, *, rid: Optional[str] = None, name: Optional[str] = None, affiliation: Optional[str] = None, create_new: bool = False) -> ResolveResult

Resolve 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.

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.

  • candidates (tuple[Candidate, …]) = field(default=())
  • confidence (str)
  • created (bool)
  • rid (Optional[str])

class Candidate(rid: str, name: str, affiliation: Optional[str] = None)

One profile an undecidable name could mean.

  • affiliation (Optional[str]) = None
  • name (str)
  • rid (str)

class ResolveError

Bases: 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.

class ProfileRow

Bases: 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.

  • affiliation (Optional[str]) = None
  • affiliation_id (Optional[str]) = None
  • anchor (Optional[dict]) = None
  • career (list)
  • career_stage_as_of (Optional[str]) = None
  • clinical_training_end_year (Optional[int]) = None
  • collaborators (list)
  • conforms_to (Optional[str]) = None
  • content_hash (str) = '': See content_hash_for.
  • critiques (list)
  • current_rank (Optional[str]) = None
  • date_modified (Optional[str]) = None: The published vintage. Nullable and never defaulted: most profiles carry no dateModified, and inventing one would be a false claim.
  • document (dict): The whole profile.jsonld payload. The source of truth.
  • email (Optional[str]) = None
  • expertise_cites_paper_ids (Optional[bool]) = None
  • field (Optional[str]) = None
  • first_independent_appointment_year (Optional[int]) = None
  • first_r01_equivalent_year (Optional[int]) = None
  • has_citation_graph (Optional[bool]) = None
  • has_embedding_index (Optional[bool]) = None
  • identifier (list)
  • independence (Optional[str]) = None
  • intellectual_lineage (list)
  • interests (list)
  • job_title (Optional[str]) = None
  • level (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]) = None
  • orcid (Optional[str]) = None: Derived from rid (NULL when the rid is local). A convenience for ORCID joins; join on rid.
  • paper_stats (Optional[dict]) = None
  • profile_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 typed rp:researchInterests entries, 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 a local: id. Indexed, not unique (see the module docstring).
  • same_as (list)
  • scholar_url (Optional[str]) = None
  • slug (str): Display handle. Unique within this store, never an identity claim.
  • subfields (list)
  • summary (Optional[str]) = None
  • tenure_status (Optional[str]) = None
  • terminal_degree_type (Optional[str]) = None
  • terminal_degree_year (Optional[int]) = None
  • training (list)
  • url (Optional[str]) = None
  • visibility (str) = 'public': Profile-level default privacy tier.

@classmethod
def from_document(meta: ProfileDocument, *, slug: str, document: Optional[dict] = None, soul: str = '') -> ProfileRow

The 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.

def take_content(fresh: ProfileRow) -> None

Copy every column of fresh except IDENTITY_COLUMNS onto this row.

class PaperRow

Bases: 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.

  • author_index (Optional[int]) = None
  • author_position (Optional[str]) = None
  • cited_by_count (Optional[int]) = None
  • doi (Optional[str]) = None
  • first_author (Optional[str]) = None
  • id (Optional[int]) = None
  • is_corresponding (Optional[bool]) = None
  • is_oa (Optional[bool]) = None
  • journal (Optional[str]) = None
  • last_author (Optional[str]) = None
  • oa_status (Optional[str]) = None
  • open_access (Optional[bool]) = None
  • openalex_id (Optional[str]) = None
  • ordinal (int) = 0: Position in the collection’s hasPart. Re-rendering papers.jsonld from these rows reproduces the original array order.
  • paper_id (Optional[str]) = None
  • pmcid (Optional[str]) = None
  • pmid (Optional[str]) = None
  • profile_id (int)
  • record (dict): The whole PaperRecord. The source of truth for this work.
  • source (Optional[str]) = None
  • title (str) = ''
  • total_authors (Optional[int]) = None
  • type (Optional[str]) = None
  • url (Optional[str]) = None
  • venue (Optional[str]) = None
  • year (Optional[int]) = None

@classmethod
def from_record(profile_id: int, paper: PaperRecord, *, ordinal: int) -> PaperRow

Build a row from a PaperRecord.

class GrantRow

Bases: 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.

  • activity_code (Optional[str]) = None
  • end (Optional[str]) = None
  • funder (Optional[str]) = None
  • grant_id (str): GrantRecord.id.
  • id (Optional[int]) = None
  • number (Optional[str]) = None
  • ordinal (int) = 0
  • profile_id (int)
  • record (dict): The whole GrantRecord. The source of truth for this award.
  • role (Optional[str]) = None
  • source (Optional[str]) = None
  • start (Optional[str]) = None
  • status (Optional[str]) = None
  • title (str) = ''
  • url (Optional[str]) = None

@classmethod
def from_record(profile_id: int, grant: GrantRecord, *, ordinal: int) -> GrantRow

Build a row from a GrantRecord.

class ExpertiseTopicRow

Bases: 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.

  • id (Optional[int]) = None
  • ordinal (int) = 0
  • profile_id (int)
  • topic (str)

class ArtifactRow

Bases: 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.

  • content_url (str): The relative path, exactly as ArtifactRef.contentUrl.
  • data (Optional[bytes]) = None
  • derived_from (list)
  • encoding_format (Optional[str]) = None
  • envelope (Optional[dict]) = None: For a rendered collection: the node minus hasPart (about, dateModified, @context, conformsTo, …).
  • id (Optional[int]) = None
  • manifest_slot (str) = DEFAULT_MANIFEST_SLOT: Which manifest list this entry belongs to: hasPart or subjectOf.
  • name (Optional[str]) = None
  • ordinal (int) = 0
  • paper_id (Optional[str]) = None
  • profile_id (int)
  • rendered (bool) = False
  • role (Optional[str]) = None
  • sha256 (Optional[str]) = None
  • size_bytes (Optional[int]) = None: ArtifactRef.bytes; see the class docstring.
  • text (Optional[str]) = None
  • type_ (Optional[str]) = None
  • visibility (str) = 'public'

@classmethod
def 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) -> ArtifactRow

Build a row from one manifest entry plus its body.

def apply_part(part: ArtifactRef) -> None

Copy 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.

def to_part() -> ArtifactRef

Rebuild the manifest entry this row records.

class ChunkVectorRow

Bases: 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.

  • backend_spec (str) = ''
  • char_count (Optional[int]) = None
  • chunk_index (int) = 0
  • dim (int) = 0
  • id (Optional[int]) = None
  • ordinal (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.json mirror, so the flat form round-trips exactly.
  • source_id (str)
  • source_type (str)
  • vector (bytes): Row-major little-endian float32, dim floats.

class ProfileVectorRow

Bases: 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.

  • backend_spec (str) = ''
  • dim (int) = 0
  • kind (str) = 'centroid'
  • profile_id (int)
  • vector (bytes): Little-endian float32, dim floats. Already L2-normalized.

class BuildStateRow

Bases: 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.

  • profile_id (int)
  • schema_version (int) = 0: BuildState’s own private counter. Build-local; never published.
  • state (dict)

class RidAliasRow

Bases: 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.

  • created_at (datetime)
  • old_rid (str)
  • successor_rid (str)

class SlugAliasRow

Bases: 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.

  • created_at (datetime)
  • old_slug (str)
  • profile_id (int)

def create_all(engine: Engine) -> None

Create every rp_* table on engine if not present.

Fresh-instance convenience only. On a deployed Postgres, column evolution goes through numbered SQL migration files.

def get_engine(url: Optional[str] = None, *, echo: bool = False) -> Engine

Return (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.

def reset_engine() -> None

Drop the cached engine (tests reconfigure the database between cases).

def get_session() -> Iterator[Session]

Yield a Session bound to the shared engine.

A generator, usable as a FastAPI dependency.

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.

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.

  • 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]): Extra WriteContext fields for the next unit this storage opens (merge_into sets retired_profile_id, retired_rid and retired_slug).
  • pending_row (Optional[ProfileRow]): The bare rp_profiles row a create inserts when its first write unit opens, on that unit’s session (see new_write_context).
  • session (Optional[Session]): The session of the open write unit, or None. Every writer goes through this; a second connection inside a unit would deadlock against rows the unit already holds.

@property
def directory -> None

Always None: rows are not a directory.

@property
def key -> str

@property
def profile_id -> Optional[int]

The rp_profiles record key.

@property
def rid_hint -> str

The subject’s rid, known without reading a document.

Lets a write unit name the profile while its document is replaced.

@property
def slug -> str

def artifact_bytes(content_url: str) -> Optional[bytes]

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.

def build_manifest() -> tuple[list[ArtifactRef], list[ArtifactRef]]

Generate the manifest from rp_artifacts.

def collection_envelope(content_url: str) -> dict

A collection node minus hasPart, so a re-render is faithful.

def commit(ctx: WriteContext) -> None

def content_hash() -> str

The stored content_hash column, refreshed by refresh_derived.

def delete_summary(paper_id: str) -> None

def index(profile: ResearcherProfile) -> NoLocalIndex

No directory, so no sqlite index handle: every operation refuses.

def load_build_state() -> BuildState

Build state from rp_build_state; an empty state when absent.

A missing table counts as absent, so dropping rp_build_state is safe.

def load_citations() -> Any

def load_document() -> ProfileDocument

def load_expertise() -> str

def load_grants() -> list[GrantRecord]

def load_papers() -> list[PaperRecord]

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.

def load_soul() -> str

def load_summaries() -> Mapping[str, str]

def locate(*parts: str) -> str

Display-only locator: a db:// reference naming the row.

def new_write_context(profile: ResearcherProfile, kind: str) -> WriteContext

Open 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.

def refresh_derived(ctx: WriteContext) -> None

Recompute 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.

def rollback(ctx: WriteContext) -> None

A real rollback: nothing this unit wrote survives.

def save_build_state(state: BuildState) -> None

def save_citations(data: Any) -> None

Write the citation graph; None DELETES the artifact.

None means absent to load_citations, so it is not stored.

def save_document(data: Mapping[str, Any]) -> None

Persist the document, and keep it consistent with rp_artifacts.

In order (the order is the contract):

  1. The incoming manifest is applied onto the artifact rows.
  2. hasPart / subjectOf are regenerated from the rows, so the document and the rows cannot disagree.
  3. The result is written through ProfileRow.from_document.

data arrives already stamped, canonicalized and re-validated.

def save_expertise(text: str) -> None

def save_grants(grants: list[GrantRecord]) -> None

def save_papers(papers: list[PaperRecord]) -> None

def save_soul(text: str) -> None

def save_summary(paper_id: str, text: str) -> None

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) -> None

Upsert 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.