Files
stack/src/prisma/project.py

799 lines
28 KiB
Python

"""PRISMA project scaffolding + criteria access.
A "project" is three Zotero items living in a dedicated collection:
- ``prisma:criteria`` — PICO inclusion/exclusion criteria (markdown note)
- ``prisma:extraction-template`` — structured form the LLM fills for
included studies (JSON-schema-ish markdown note)
- ``prisma:reasons`` — the exclusion-reason codebook
Storing criteria this way (vs. git-tracked docs) keeps them with the
library that's being screened and lets non-engineer reviewers edit in
Zotero. Downside: edits aren't automatically versioned; if you care,
commit the markdown into docs/ as a mirror.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from zot.db import TYPE_MAP, generate_key, now_iso
if TYPE_CHECKING:
from zot.db import Db
# Tag markers for the three per-project anchor items.
TAG_CRITERIA = "prisma:criteria"
TAG_EXTRACTION = "prisma:extraction-template"
TAG_REASONS = "prisma:reasons"
COLLECTION_ROOT = "PRISMA Projects"
@dataclass
class Project:
name: str # e.g. "skin-subs" — matches project:<name> tag
criteria: str # markdown
extraction_template: str # markdown / JSON-schema-in-markdown
reasons: str # markdown codebook
@property
def project_tag(self) -> str:
return f"project:{self.name}"
# ── Bootstrap ────────────────────────────────────────────────────
_STARTER_CRITERIA_SKIN_SUBS = """\
# Skin Substitutes — Inclusion / Exclusion Criteria
## Review question
Do cellular/tissue-based or biologically-derived skin substitute
products improve wound healing outcomes compared to standard wound
care in adult patients with chronic lower-extremity wounds
(DFU, VLU, pressure ulcers, burns)?
## PICO
- **Population** — adults (≥18y) with chronic non-healing wounds:
diabetic foot ulcers (DFU), venous leg ulcers (VLU), pressure
ulcers, or burns. Exclude purely cosmetic dermatology and acute
surgical wound closure without chronicity context.
- **Intervention** — any cellular or tissue-based product (CTP),
tissue-engineered, bioengineered, xenograft, or allograft skin
substitute. Named products include Apligraf, Dermagraft, EpiFix,
MatriStem, PriMatrix, Oasis, AmnioBand, Affinity, Integra, and
similar. Topical wound dressings alone do **not** qualify.
- **Comparator** — standard wound care (debridement + offloading /
compression + moist wound dressing), head-to-head against another
skin substitute, or placebo.
- **Outcomes** — primary: complete wound closure at 12 weeks
(or nearest reported). Secondary: time to closure, percent area
reduction (PAR), infection rate, recurrence, quality of life,
cost per healed wound, QALYs.
## Study designs
Include: RCT, quasi-RCT, prospective cohort with comparator,
systematic review, meta-analysis, economic evaluation alongside any
of the above.
Exclude: case reports, case series without comparator, narrative
reviews, editorials, letters, commentaries, conference abstracts
without full data.
## Time / language
Any publication year. English, or non-English with available English
abstract.
## Policy / fraud sub-domain
Docs tagged with any of ``source:oig``, ``source:gao``, ``source:medpac``,
``source:court``, ``source:cms-rule``, ``source:mac-lcd`` are reference
material and bypass clinical screening. Flag with
``screen:reason:off-topic`` only if unrelated to skin substitutes.
"""
_STARTER_EXTRACTION = """\
# Data extraction template
The LLM will fill this form for every included study. Fields
absent from the paper should be returned as empty strings rather
than omitted.
## Study metadata
- **authors** — lead + et al.
- **year** — publication year (YYYY)
- **country** — setting of the study
- **funding** — industry-funded (name sponsor) / public / mixed /
unreported
- **coi_declared** — yes / no / partial
## Population
- **n_total** — total enrolled
- **n_analyzed** — included in primary analysis
- **wound_type** — DFU / VLU / pressure / burn / mixed
- **baseline_wound_area_cm2** — mean (SD) or median (IQR)
- **inclusion_notes** — key eligibility criteria
## Intervention
- **product_name** — commercial / generic name
- **manufacturer** — sponsor
- **product_category** — CTP / synthetic / xenograft / amniotic /
other
- **application_frequency** — e.g. weekly, until closure or 12 wk
- **concomitant_care** — offloading, compression, debridement
## Comparator
- **comparator_type** — standard care / active / placebo
- **comparator_detail**
## Outcomes
- **primary_outcome** — definition
- **primary_result_treatment** — e.g. 62% healed at 12 wk
- **primary_result_comparator** — e.g. 34% healed at 12 wk
- **effect_size** — OR / RR / HR / mean diff with 95% CI
- **p_value**
- **time_to_closure_days** — median (IQR)
- **adverse_events** — brief text
- **cost_per_healed** — USD if reported
## Risk of bias (Cochrane RoB-2 domains, if applicable)
- **randomization** — low / some concerns / high
- **deviations_from_intervention**
- **missing_outcome_data**
- **measurement_of_outcome**
- **selection_of_reported_result**
- **overall** — low / some concerns / high
"""
_STARTER_REASONS = """\
# Exclusion reason codebook
Each code applies to a single PRISMA stage. When the LLM decides to
exclude a record it must return one of these codes plus a one-sentence
rationale.
## Stage 2 — title / abstract screening
- `not-skin-substitute` — intervention is not a cellular or
tissue-based skin substitute.
- `wrong-population` — not chronic lower-extremity wounds or burns.
- `case-report` — case report / case series without comparator.
- `editorial` — editorial, commentary, narrative review, letter.
- `conference-abstract` — abstract only, no full data.
- `not-english` — not English and no English abstract available.
- `duplicate` — duplicate publication (same data, different venue).
- `retracted` — paper has been retracted.
- `off-topic` — unrelated to skin substitutes altogether.
## Stage 3 — full-text eligibility
Everything in Stage 2, plus:
- `no-comparator` — no control group.
- `wrong-outcome` — does not report any of the primary or secondary
outcomes in the review's PICO.
- `unavailable-fulltext` — full text cannot be retrieved.
## Policy / fraud sub-domain (OIG, GAO, MedPAC, court, CMS rules)
Included by default; excluded only with code:
- `off-topic` — unrelated to skin substitutes.
"""
_STARTER_CRITERIA_PALLIATIVE_RFI = """\
# Community-Based Palliative Care RFI — Inclusion / Exclusion Criteria
## Review question
What evidence can inform CMS's Request for Information on
Community-Based Palliative Care in the CY2027 PFS NPRM (CMS-1848-P,
91 FR 43949-43950)? The RFI asks five things, and a record is
included when it provides usable evidence for at least one:
- **(a)** where fraud, waste, and abuse concentrate in
community-based palliative care and adjacent hospice programs;
- **(b)** whether eligibility for a serious-illness service should be
gated on certified life expectancy, and what prognostic interval
the evidence supports;
- **(c)** whether eligibility is better defined by impact on daily
function (ADLs) and/or caregiver strain, and with what instruments;
- **(d)** which service elements of community-based palliative care
management (interdisciplinary teams, home visits, 24/7 access,
care plans, post-discharge follow-up) drive outcomes;
- **(e)** how quality of palliative care management should be
measured (including CBE 3665, "feeling heard and understood");
- **(f)** informs the design of a proposed palliative care management
code family (palliative care screening/initial assessment;
palliative care planning; palliative care follow-up) — program
standards, interdisciplinary team composition and staffing, service
elements, assessment instruments, and documentation practices
(CAPC / NASW / Joint Commission anchored).
## Population
Adults with serious illness — palliative care, hospice, or
supportive-care populations. U.S. Medicare-relevant settings
preferred; non-U.S. evidence is includable when the finding
transfers (instruments, service elements, prognostic accuracy).
Exclude pediatric-only populations.
## Topic scope
Include: service-delivery models (community-based, home-based,
outpatient/ambulatory palliative care), eligibility/identification
criteria and instruments, prognostication accuracy, caregiver strain
measurement, care-management service elements, quality measures and
patient-reported outcomes, utilization/cost/hospitalization outcomes,
billing-integrity and program-integrity evidence.
Exclude: pure symptom-pharmacology efficacy trials (drug/dose
comparisons) with no service-delivery, eligibility, or quality
measurement angle; inpatient-only intervention studies with no
community/outpatient transferability; disease-specific prognostic
biomarker papers with no service-eligibility framing.
## Study designs
Include: RCT, quasi-experimental, prospective/retrospective cohort
with comparator, validation studies of instruments or prognostic
criteria, systematic review, meta-analysis, economic evaluation,
health-services/claims analyses, mixed-methods evaluations of
delivery models.
Exclude: case reports, case series without comparator, narrative
reviews, editorials, letters, commentaries, conference abstracts
without full data.
## Time / language
Any publication year (CMMI's synthesis window is 2012-2021; older
foundational instrument papers still qualify). English, or
non-English with available English abstract.
## Policy / grey sub-domain
Docs tagged with any of ``source:oig``, ``source:gao``, ``source:medpac``,
``source:court``, ``source:cms-rule``, ``source:mac-lcd`` are reference
material and bypass clinical screening. Flag with
``screen:reason:off-topic`` only if unrelated to palliative or
hospice care.
"""
_STARTER_EXTRACTION_PALLIATIVE_RFI = """\
# Data extraction template — palliative-rfi
The LLM will fill this form for every included study. Fields absent
from the paper should be returned as empty strings rather than
omitted.
## Study metadata
- **authors** — lead + et al.
- **year** — publication year (YYYY)
- **country** — setting of the study
- **funding** — industry / public / mixed / unreported
- **design** — RCT / quasi-experimental / cohort / validation /
systematic review / meta-analysis / economic / claims analysis
## RFI mapping
- **rfi_parts** — which RFI questions the study informs (a-e,
comma-separated)
- **medicare_relevance** — direct (Medicare data/population) /
transferable / weak
## Population & setting
- **n_total** — total enrolled or analyzed
- **population** — serious-illness definition used
- **setting** — home / outpatient clinic / community / mixed /
inpatient
## Eligibility evidence (parts b-c)
- **eligibility_basis** — prognosis / function-ADL / caregiver-strain /
diagnosis-count / combination
- **instrument** — named instrument or criteria set
- **prognostic_interval** — life-expectancy interval studied
- **instrument_performance** — accuracy / discrimination as reported
## Delivery-model evidence (part d)
- **service_elements** — team composition, home visits, 24/7 access,
care planning, follow-up cadence as reported
- **outcomes** — utilization, hospitalization, cost, place of death,
patient/caregiver-reported outcomes
- **effect_size** — OR / RR / HR / mean diff with 95% CI
- **p_value**
## Quality-measure evidence (part e)
- **measures_used** — named quality measures / PROMs (note CBE 3665)
- **measure_findings**
## Integrity evidence (part a)
- **fwa_findings** — billing-pattern, enforcement, or integrity
findings
## Risk of bias
- **overall** — low / some concerns / high
- **rob_notes**
"""
_STARTER_REASONS_PALLIATIVE_RFI = """\
# Exclusion reason codebook — palliative-rfi
Each code applies to a single PRISMA stage. When the LLM decides to
exclude a record it must return one of these codes plus a one-sentence
rationale.
## Stage 2 — title / abstract screening
- `off-topic` — unrelated to palliative, hospice, or serious-illness
care.
- `wrong-population` — pediatric-only, or not a serious-illness /
palliative / hospice population.
- `symptom-pharmacology` — drug/dose efficacy study with no
service-delivery, eligibility, or quality-measurement angle.
- `inpatient-only` — exclusively inpatient/ICU intervention with no
community, home, or outpatient transferability.
- `no-rfi-question` — on-topic but informs none of RFI parts a-e.
- `case-report` — case report / case series without comparator.
- `editorial` — editorial, commentary, narrative review, letter.
- `conference-abstract` — abstract only, no full data.
- `not-english` — not English and no English abstract available.
- `duplicate` — duplicate publication (same data, different venue).
- `retracted` — paper has been retracted.
## Stage 3 — full-text eligibility
Everything in Stage 2, plus:
- `no-usable-evidence` — full text reports no findings usable for any
RFI question.
- `wrong-outcome` — outcomes unrelated to eligibility, delivery,
quality, cost/utilization, or integrity.
- `unavailable-fulltext` — full text cannot be retrieved.
## Policy / grey sub-domain (OIG, GAO, MedPAC, court, CMS rules)
Included by default; excluded only with code:
- `off-topic` — unrelated to palliative or hospice care.
"""
_LEAD_SUSPECTING = """\
Suspecting is in scope as both a code uplift and a care change. Each is a
hypothesized status transition, such as not High Needs to High Needs, an
absent HCC becoming present, or a quality denominator without a numerator
gaining one. A code uplift makes an existing status visible to the math.
Care changes the underlying state. Record how far each can move the score,
including when demographic reweighting shrinks the coding payoff.
"""
_LEAD_CRITERIA_HISTORY = """\
# LEAD history — inclusion criteria
Strand 1. For each target in the LEAD registry, find where the component
came from, what assumption it rests on, what evidence supported it when it
was adopted and what has appeared since, and how it changed through MA,
MSSP, Pioneer, NGACO, and REACH on the way to LEAD.
Include primary CMS methodology, the papers those methods cite, and
critiques of the same component. Exclude a record that names no registry
target and no predecessor of one.
"""
_LEAD_EXTRACTION_HISTORY = """\
# Extraction — lead-history
- **target_id** — registry id this row is about
- **origin** — where the component came from
- **assumptions** — the assumption it rests on
- **supporting_evidence** — evidence at adoption and since
- **critiques** — published critiques of the component
- **changes_to_lead** — how it changed on the way to LEAD
- **status_in_lead** — adopted / modified / dropped
- **page_refs** — page or section locators
"""
_LEAD_REASONS_HISTORY = """\
# Exclusion reasons — lead-history
- `off-topic` — names no registry target and no predecessor of one.
- `editorial` — editorial, commentary, or letter with no historical claim.
- `not-english` — no English text.
- `unavailable-fulltext` — full text cannot be retrieved.
"""
_LEAD_CRITERIA_LIMITS = f"""\
# LEAD limits — inclusion criteria
Strand 2. For each registry target, the mathematical and empirical limits
on predictive accuracy, how far an ACO's actions can move the scored
result, bias across subgroups, and incentive distortion.
{_LEAD_SUSPECTING}
Include a limit when it states a bound, a formula, or a number that can
be checked. Exclude commentary that names no limit.
"""
_LEAD_EXTRACTION_LIMITS = """\
# Extraction — lead-limits
- **target_id** — registry id the limit applies to
- **limit_type** — accuracy / movability / fairness / incentive
- **statement** — the limit in one or two sentences
- **formula** — formula or bound, when the source gives one
- **empirical_value** — number the source reports, with its unit
- **computable_expression** — expression that can run on public data
- **page_refs** — page or section locators
"""
_LEAD_REASONS_LIMITS = """\
# Exclusion reasons — lead-limits
- `off-topic` — not a limit on a registry target.
- `no-limit` — on topic but states no bound, formula, or checkable number.
- `editorial` — editorial, commentary, or letter.
- `not-english` — no English text.
- `unavailable-fulltext` — full text cannot be retrieved.
"""
_LEAD_CRITERIA_SCAN = """\
# LEAD environment scan — inclusion criteria
Strand 3, from 2023 onward. What surrounds LEAD: adjacent policy, how the
industry identifies High Needs patients, public data sources and their
grain, current methods for risk and prognosis, and open-model candidates
with their licenses.
Include a dated item the chassis would have to notice. Exclude undated
background with no consequence for the model.
"""
_LEAD_EXTRACTION_SCAN = """\
# Extraction — lead-scan
- **item_type** — policy / method / data / model / industry
- **item_date** — date or year of the item
- **summary** — what the item says, in a few sentences
- **target_ids** — registry ids it bears on, if any
- **aacods** — AACODS appraisal notes
- **chassis_meaning** — what the chassis should do because of this item
"""
_LEAD_REASONS_SCAN = """\
# Exclusion reasons — lead-scan
- `off-topic` — not about the environment LEAD is built in.
- `undated` — no date, and no way to place it in 2023 or later.
- `editorial` — color with no fact the chassis can use.
- `not-english` — no English text.
- `unavailable-fulltext` — full text cannot be retrieved.
"""
_LEAD_CRITERIA_PRECURSORS = f"""\
# LEAD precursors — inclusion criteria
Strand 4, a prognostic-factor review. The population is Medicare
beneficiaries, or comparable older, disabled, or ESRD adults, in claims
or EHR cohorts. An index factor is measured before the outcome. The
outcome is a registry target or a named proxy, and the lead time is
recorded. Eligible designs are cohort, case-control, prediction-model
development or validation, and quasi-experimental. Grain may be the
beneficiary, the provider, the ACO, or an area.
A cross-sectional study with no temporal order is excluded.
{_LEAD_SUSPECTING}
"""
_LEAD_EXTRACTION_PRECURSORS = """\
# Extraction — lead-precursors
- **design** — study design as reported
- **data_source** — claims, EHR, registry, or survey named by the paper
- **population** — who was studied
- **n** — sample size
- **setting** — care setting
- **country** — country of the data
- **years** — years the data cover
- **factor_definition** — how the precursor was defined
- **outcome_target_id** — registry id of the outcome, or of its proxy
- **proxy_closeness** — how close a proxy is to the registry target
- **lead_time** — time from factor to outcome
- **grain** — beneficiary / provider / ACO / area
- **effect_measure** — measure the paper reports
- **effect_value** — point estimate
- **effect_ci** — confidence interval or other uncertainty
- **adjusted_for** — covariates the estimate adjusts for
- **c_statistic** — discrimination, when reported
- **calibration** — calibration, when reported
- **risk_of_bias** — bias appraisal, with the tool named
- **observable_public** — whether public data can supply the factor
- **observable_claims** — whether claims can supply the factor
"""
_LEAD_REASONS_PRECURSORS = """\
# Exclusion reasons — lead-precursors
- `off-topic` — not a precursor of a registry target.
- `wrong-population` — not Medicare or a comparable older, disabled, or ESRD cohort.
- `cross-sectional-no-time-order` — cross-sectional study with no temporal order.
- `editorial` — editorial, commentary, or letter with no study result.
- `not-english` — no English text.
- `unavailable-fulltext` — full text cannot be retrieved.
- `wrong-outcome` — outcome is not a registry target and not a named proxy.
"""
_LEAD_CRITERIA_EXTERNAL = """\
# LEAD external forces — inclusion criteria
Strand 5. How forces outside the ACO move LEAD's inputs and scores: labor
markets and prices, legislation and rulemaking, tax and coverage policy,
and market structure.
Include a series or a dated event when the record states a mechanism that
reaches a registry target. Exclude a series with no stated path into the
score.
"""
_LEAD_EXTRACTION_EXTERNAL = """\
# Extraction — lead-external
- **series_or_event** — name of the series or the dated event
- **dates** — period or date the record covers
- **geography** — geography the figure applies to
- **source** — agency or publication the figure comes from
- **mechanism** — how this force reaches a LEAD input or score
- **magnitude** — size of the move, with its unit
"""
_LEAD_REASONS_EXTERNAL = """\
# Exclusion reasons — lead-external
- `off-topic` — no path into a LEAD input or score.
- `no-mechanism` — a series or event with no stated mechanism.
- `editorial` — commentary with no series and no dated event.
- `not-english` — no English text.
- `unavailable-fulltext` — full text cannot be retrieved.
"""
STARTERS = {
"skin-subs": (
_STARTER_CRITERIA_SKIN_SUBS,
_STARTER_EXTRACTION,
_STARTER_REASONS,
),
"palliative-rfi": (
_STARTER_CRITERIA_PALLIATIVE_RFI,
_STARTER_EXTRACTION_PALLIATIVE_RFI,
_STARTER_REASONS_PALLIATIVE_RFI,
),
"lead-history": (
_LEAD_CRITERIA_HISTORY,
_LEAD_EXTRACTION_HISTORY,
_LEAD_REASONS_HISTORY,
),
"lead-limits": (
_LEAD_CRITERIA_LIMITS,
_LEAD_EXTRACTION_LIMITS,
_LEAD_REASONS_LIMITS,
),
"lead-scan": (
_LEAD_CRITERIA_SCAN,
_LEAD_EXTRACTION_SCAN,
_LEAD_REASONS_SCAN,
),
"lead-precursors": (
_LEAD_CRITERIA_PRECURSORS,
_LEAD_EXTRACTION_PRECURSORS,
_LEAD_REASONS_PRECURSORS,
),
"lead-external": (
_LEAD_CRITERIA_EXTERNAL,
_LEAD_EXTRACTION_EXTERNAL,
_LEAD_REASONS_EXTERNAL,
),
}
# Legacy cohort tag → canonical project tag mapping. Items tagged
# ``module:skin-subs`` pre-date PRISMA; init() backfills ``project:<name>``
# onto them so the screening queue has something to chew on.
_LEGACY_COHORT_TAGS: dict[str, str] = {
"skin-subs": "module:skin-subs",
"palliative-rfi": "module:palliative-rfi",
}
def init(db: Db, name: str) -> Project:
"""Create or find the three anchor items for a PRISMA project.
Idempotent: re-running returns the existing records (matched by
``project:<name> + prisma:*`` tags). Edits to criteria happen in
Zotero; we don't overwrite user changes.
"""
if name not in STARTERS:
raise ValueError(
f"no starter criteria defined for project {name!r}. "
f"Known projects: {sorted(STARTERS)}"
)
criteria_md, extraction_md, reasons_md = STARTERS[name]
project_tag = f"project:{name}"
root_key = db.ensure_collection(COLLECTION_ROOT)
_ = db.ensure_collection(name, parent_key=root_key)
_ensure_anchor(
db,
project_tag,
TAG_CRITERIA,
root_key,
title=f"Screening criteria — {name}",
note_md=criteria_md,
)
_ensure_anchor(
db,
project_tag,
TAG_EXTRACTION,
root_key,
title=f"Data extraction template — {name}",
note_md=extraction_md,
)
_ensure_anchor(
db,
project_tag,
TAG_REASONS,
root_key,
title=f"Exclusion reason codebook — {name}",
note_md=reasons_md,
)
if name in _LEGACY_COHORT_TAGS:
_backfill_cohort(db, legacy=_LEGACY_COHORT_TAGS[name], project_tag=project_tag)
return Project(
name=name,
criteria=_load_note(db, project_tag, TAG_CRITERIA),
extraction_template=_load_note(db, project_tag, TAG_EXTRACTION),
reasons=_load_note(db, project_tag, TAG_REASONS),
)
def _backfill_cohort(db: Db, *, legacy: str, project_tag: str) -> int:
"""Tag every item carrying *legacy* with *project_tag* (idempotent)."""
# Resolve or create the target tag.
row = db.con.execute(
"SELECT tagID FROM tags WHERE name = ?", (project_tag,)
).fetchone()
if row:
target_id = row[0]
else:
cur = db.con.execute(
"INSERT INTO tags (name) VALUES (?)",
(project_tag,),
)
target_id = cur.lastrowid
# Items with legacy cohort tag that don't already carry project_tag.
affected = db.con.execute(
"""
INSERT OR IGNORE INTO itemTags (itemID, tagID, type)
SELECT i.itemID, ?, 1
FROM items i
JOIN itemTags it ON i.itemID = it.itemID
JOIN tags t ON it.tagID = t.tagID
WHERE t.name = ?
AND NOT EXISTS (
SELECT 1 FROM itemTags ex
WHERE ex.itemID = i.itemID AND ex.tagID = ?
)
""",
(target_id, legacy, target_id),
).rowcount
db.commit()
return affected
def load(db: Db, name: str) -> Project:
"""Load an existing project's three documents from Zotero."""
project_tag = f"project:{name}"
return Project(
name=name,
criteria=_load_note(db, project_tag, TAG_CRITERIA),
extraction_template=_load_note(db, project_tag, TAG_EXTRACTION),
reasons=_load_note(db, project_tag, TAG_REASONS),
)
# ── Internals ────────────────────────────────────────────────────
def _ensure_anchor(
db: Db,
project_tag: str,
role_tag: str,
collection_key: str,
*,
title: str,
note_md: str,
) -> int:
"""Return the anchor item's Zotero itemID, creating if absent."""
existing = _find_anchor_id(db, project_tag, role_tag)
if existing is not None:
return existing
item_key = generate_key()
item_id = db.create_item(TYPE_MAP["document"], key=item_key, now=now_iso())
db.set_fields(item_id, {"title": title})
db.sync_tags(item_id, [project_tag, role_tag, "module:prisma"])
db.add_to_collection(item_id, collection_key=collection_key)
# Zotero notes store HTML. Wrap the markdown verbatim inside a
# <pre> block so it survives a round-trip and stays legible in the
# UI — reviewers can still edit it, and our loader strips the
# wrapper when reading it back.
db.add_note(
item_id,
f"<pre>{_html_escape(note_md)}</pre>",
title=title,
)
db.commit()
return item_id
def _find_anchor_id(db: Db, project_tag: str, role_tag: str) -> int | None:
row = db.con.execute(
"""
SELECT it1.itemID
FROM itemTags it1
JOIN tags t1 ON it1.tagID = t1.tagID AND t1.name = ?
JOIN itemTags it2 ON it1.itemID = it2.itemID
JOIN tags t2 ON it2.tagID = t2.tagID AND t2.name = ?
LIMIT 1
""",
(project_tag, role_tag),
).fetchone()
return row[0] if row else None
def _load_note(db: Db, project_tag: str, role_tag: str) -> str:
item_id = _find_anchor_id(db, project_tag, role_tag)
if item_id is None:
raise LookupError(
f"project anchor not found: {project_tag} + {role_tag}. "
f"Run `stack prisma init <project>` first."
)
row = db.con.execute(
"SELECT note FROM itemNotes WHERE parentItemID = ? LIMIT 1",
(item_id,),
).fetchone()
if not row:
raise LookupError(f"anchor item {item_id} has no note attached")
raw = row[0] or ""
# Strip the <pre>…</pre> wrapper we put in on create (best-effort;
# if a reviewer pasted over it with plain HTML, we surface whatever
# they wrote).
stripped = raw.strip()
if stripped.startswith("<pre>") and stripped.endswith("</pre>"):
stripped = stripped[5:-6]
return _html_unescape(stripped)
def _html_escape(s: str) -> str:
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
def _html_unescape(s: str) -> str:
return s.replace("&lt;", "<").replace("&gt;", ">").replace("&amp;", "&")