Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ An open-source, transfemme-focused voice-training practice companion.

Contributions are welcome through issues and pull requests. Read [CONTRIBUTING.md](CONTRIBUTING.md), [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md), and [SECURITY.md](SECURITY.md) before contributing.

The [Product Vision](docs/product-vision.md) explains the long-term learning experience and non-negotiable product principles. The evidence, safety boundaries, and feature-design principles are public in the [Research and Practice Guide](docs/research-guide.md). It is the source of truth for training-related product changes. The [Implementation Roadmap](docs/roadmap.md) records the active academy milestones, decisions, and known limitations. The [Lesson Engine Architecture](docs/lesson-engine.md) documents the versioned content contract for contributors, and [Foundations Course: First Four Lessons](docs/foundations-lessons.md) documents the first real lesson set. The [iOS Readiness Plan](docs/ios-readiness.md) documents the decisions that keep a future native app possible without a rewrite.
The [Product Vision](docs/product-vision.md) explains the long-term learning experience and non-negotiable product principles. The evidence, safety boundaries, and feature-design principles are public in the [Research and Practice Guide](docs/research-guide.md). It is the source of truth for training-related product changes. The [Implementation Roadmap](docs/roadmap.md) records the active academy milestones, decisions, and known limitations. The [Lesson Engine Architecture](docs/lesson-engine.md) documents the versioned content contract, the [Educational Media Pipeline](docs/media-pipeline.md) documents governed teaching assets, and [Foundations Course: First Four Lessons](docs/foundations-lessons.md) documents the first real lesson set. The [iOS Readiness Plan](docs/ios-readiness.md) documents the decisions that keep a future native app possible without a rewrite.

FemmeVoice is released under the [MIT License](LICENSE).

Expand Down
35 changes: 34 additions & 1 deletion docs/media-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,37 @@ Milestone 6 treats educational media as versioned teaching content, not an untra

Kind-specific publication requirements are enforced at the content boundary: images need alternative text; audio needs a transcript; video needs both transcript and captions. A replacement creates a new asset version so published lesson revisions remain reproducible. Localization assets link to the same conceptual asset id with their own locale and review.

The first validation fixture is the existing `public/academy/voice-pathway.jpg` illustration. Upload storage, author UI, and lesson asset references follow only after this contract is proven with real reviewed media.
The first validation fixture is the existing `public/academy/voice-pathway.jpg` illustration. Its source file, byte size, checksum, rights, alternative text, and review state are maintained as one governed metadata revision.

## Lifecycle

Media follows the same separation of duties as courses and lessons:

1. An author creates or updates a private draft.
2. Review submission validates that the source is real rather than a placeholder, the checksum and byte size are present, rights are documented, and the required accessible alternative exists.
3. A reviewer records content, research, and accessibility checks.
4. A publisher releases an approved immutable revision.
5. Learners can receive only published revisions through `GET /api/academy/media`.

Incomplete drafts are intentionally saveable. This lets an author record a useful asset before a transcript, caption file, rights confirmation, or final binary is ready, without weakening the publication boundary.

## Admin API

- `GET /api/admin/academy/media` lists revisions for Academy roles.
- `GET /api/admin/academy/media/:id/:version/:locale` loads one revision.
- `PUT /api/admin/academy/media/:id/:version/:locale` saves a private author draft.
- `PUT .../submit-review` requests review after publication-readiness validation.
- `PUT .../review` records the independent reviewer decision.
- `PUT .../publish` publishes an approved immutable revision.

The Admin Media Library provides structured fields for identity, source, checksum, rights, kind-specific accessibility, replacement lineage, and localization lineage. It does not expose raw JSON as the normal workflow.

## Public Delivery

`GET /api/academy/media` is anonymous, read-only, version-aware, and cacheable. It returns the latest published revision for each asset identity and locale and omits private review notes and identities. Drafts and unpublished revisions never enter the manifest.

Binary storage remains separate from metadata governance. This slice accepts reviewed local paths or HTTPS sources; it does not put educational media into the encrypted learner-recording vault. A later Milestone 6 slice must choose an upload/CDN strategy before large production audio or video is accepted.

## Next Boundary

Lesson blocks still use their existing bundled source paths. The next slice will add deterministic governed asset references and a resolver that prefers the published manifest while retaining safe bundled recovery content. Interactive Practice remains a separate backlog initiative.
21 changes: 15 additions & 6 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,18 @@

**Status:** living implementation guide
**Last updated:** 17 July 2026
**Current development phase:** Milestone 5 authoring closure, ready for validation before Milestone 6 resumes
**Current development phase:** Milestone 6 educational media governance and delivery

## Active Development

| Item | Current state |
| --- | --- |
| Current milestone | Milestone 5 authoring closure: blank creation and explicit revision paths |
| Current version | `v0.3.30` |
| Current working branch | `fix/academy-authoring-closure` |
| Active pull request(s) | PR #19 - Academy authoring closure; PR #18 is integrated into `main`. |
| Current milestone | Milestone 6 - Educational Media Pipeline |
| Current version | `v0.3.32` |
| Current working branch | `feat/academy-media-library` |
| Active pull request(s) | PR #20 - Governed Academy media library; PR #19 is integrated into `main`. |
| Base branch | `main` is canonical. |
| Next planned milestone | Resume Milestone 6 - Educational Media Pipeline |
| Next planned milestone | Continue Milestone 6 with deterministic lesson asset references and public resolution. |
| Overall completion estimate | About 40% of the long-term Academy vision; engine, history, governance, authoring, and delivery are complete, while media, remaining curriculum, and coaching remain substantial work. |

## Vision
Expand Down Expand Up @@ -54,6 +54,8 @@ The definitive product direction is the [Product Vision](product-vision.md). Voi
- `v0.3.28` - Milestone 5 end-to-end governance validation
- `v0.3.29` - Versioned educational media contract
- `v0.3.30` - Blank Academy authoring and immutable course/lesson revision paths
- `v0.3.31` - Governed Academy media library and publication workflow
- `v0.3.32` - Governed media-library pull-request readiness

Update this list whenever a versioned change is pushed so milestones, pull requests, and releases remain easy to correlate.

Expand Down Expand Up @@ -430,6 +432,11 @@ Milestone 5 ends when a contributor can comfortably maintain the complete Founda
**In progress**
- [x] Versioned media contract covers locale, source, rights, accessibility metadata, and three-part review.
- [x] Existing Foundations voice-pathway illustration validates as the first real asset fixture.
- [x] Read-only public media manifest exposes only published revisions and carries cache directives.
- [x] Admin Media Library supports draft, review, publish, replacement, and localization metadata workflows.
- [x] Drafts may be incomplete, while review submission rejects placeholders and missing rights or accessible alternatives.
- [ ] Lesson media blocks reference deterministic governed asset revisions and resolve them through the public manifest.
- [ ] Validate replacement and localization resolution with real Foundations lesson content.

### Milestone 7 - Remaining Foundations Lessons

Expand Down Expand Up @@ -470,6 +477,7 @@ The existing `interactive_exercise` block remains suitable for simple learner-le
- Academy history must remain a separate versioned contract. Milestone 4A establishes useful local history; Milestone 4B owns migration, retention, export, deletion, multi-device conflict handling, and explicit sync consent.
- Existing production course records require the documented one-time version-index migration before a second course revision is saved.
- Public media needs a storage/CDN strategy; do not put large course video into the existing encrypted-recording vault.
- Media metadata governance is implemented, but binary upload/storage is intentionally pending a dedicated storage decision. Current assets use reviewed local or HTTPS sources.
- Lesson authoring must enforce captions, transcripts, evidence, and safety metadata, or content quality will drift.
- Community features require funded human moderation and safeguarding; they are not a learner MVP dependency.
- Native iOS work depends on later API-token and audio-provider boundaries described in the iOS readiness plan.
Expand Down Expand Up @@ -530,3 +538,4 @@ The existing `interactive_exercise` block remains suitable for simple learner-le
- **2026-07-17:** Added an isolated Docker Compose staging stack and guarded development-account seed command for the author, reviewer, publisher, and administrator workflow. It is the required environment for the final Milestone 5 publication demonstration.
- **2026-07-17:** Milestone 5 completed after a clean staging run proved author/reviewer/publisher permissions, draft privacy, four-lesson Foundations publication, cacheable public delivery, desktop/mobile learner rendering, bundled fallback behavior, and clean-volume reproducibility.
- **2026-07-17:** A roadmap truth audit found missing blank-authoring controls and no practical revision path for published courses. The closure patch adds blank documents, explicit next-version actions, versioned course lineage, and deterministic lesson references before Milestone 6 resumes.
- **2026-07-17:** Milestone 6 begins with a governed media library. Authors can save incomplete private drafts, but only real checksummed assets with complete rights and kind-specific accessibility metadata may enter role-separated review and publication. Published metadata is exposed through a cacheable read-only manifest; lesson resolution follows in the next focused slice.
2 changes: 2 additions & 0 deletions docs/staging-academy.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,5 @@ The seed script refuses to run unless `FEMMEVOICE_ENV=staging`; Compose uses a s
The local Compose profile explicitly disables the cookie `Secure` attribute because it is served over loopback HTTP. Production defaults to secure cookies and must not reuse this override.

The Milestone 5 validation used separate author, reviewer, and publisher sessions. Draft and approved-but-unpublished records returned an empty public catalogue. After all four Foundations lessons and the course were published, the catalogue returned one course with four lessons and `Cache-Control: public, max-age=60, stale-while-revalidate=300`. A clean `down -v` and restart recreated an empty isolated database and the accounts seeded successfully again.

Milestone 6 media validation uses the same identities in the **Educational media** section. Save a private asset draft as the author, submit it only after its source, checksum, rights, and accessible alternative are complete, approve all three review areas as the reviewer, and publish as the publisher. `GET /api/academy/media` must remain empty before publication and return the published revision with `Cache-Control: public, max-age=300, stale-while-revalidate=3600` afterward. Published media fields are read-only; replacements and localizations begin as new private revisions.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "femmevoice",
"version": "0.3.30",
"version": "0.3.32",
"private": true,
"type": "module",
"scripts": {
Expand Down
117 changes: 104 additions & 13 deletions server/academy_media.py
Original file line number Diff line number Diff line change
@@ -1,33 +1,124 @@
from copy import deepcopy
import re
from urllib.parse import urlparse

MEDIA_SCHEMA_VERSION = 1
MEDIA_KINDS = {"audio", "video", "image", "document"}
MEDIA_STATES = {"draft", "review_requested", "in_review", "published"}
REVIEW_KEYS = ("content_checked", "research_checked", "accessibility_checked")


def validate_media_asset(asset):
def validate_media_asset(asset, require_review=True, publication_ready=True):
if not isinstance(asset, dict):
raise ValueError("Media asset must be an object.")
required = ("id", "version", "kind", "locale", "source", "title", "rights", "accessibility", "review")
required = ("id", "version", "kind", "locale", "source", "title", "mimeType", "byteSize", "checksum", "rights", "accessibility", "review")
if any(key not in asset for key in required):
raise ValueError("Media asset is missing required fields.")
if asset.get("schemaVersion", MEDIA_SCHEMA_VERSION) != MEDIA_SCHEMA_VERSION:
raise ValueError("Media asset schema version is unsupported.")
if not _text(asset["id"], 120) or not isinstance(asset["version"], int) or asset["version"] < 1:
raise ValueError("Media asset identity is invalid.")
if asset["kind"] not in MEDIA_KINDS or not _text(asset["locale"], 20) or not _text(asset["source"], 2000) or not _text(asset["title"], 300):
if asset["kind"] not in MEDIA_KINDS or not _text(asset["locale"], 20) or not _safe_source(asset["source"]) or not _text(asset["title"], 300):
raise ValueError("Media asset metadata is invalid.")
if not isinstance(asset["rights"], dict) or not _text(asset["rights"].get("owner"), 300) or not _text(asset["rights"].get("license"), 300):
if not _text(asset["mimeType"], 120) or not isinstance(asset["byteSize"], int) or not 0 <= asset["byteSize"] <= 5_000_000_000:
raise ValueError("Media type or byte size is invalid.")
if not isinstance(asset["checksum"], str) or not re.fullmatch(r"sha256:[a-fA-F0-9]{64}", asset["checksum"]):
raise ValueError("Media assets need a SHA-256 checksum.")
_validate_rights(asset["rights"], require_complete=publication_ready)
_validate_accessibility(asset["kind"], asset["accessibility"], require_complete=publication_ready)
if publication_ready and (asset["byteSize"] < 1 or asset["checksum"] == f"sha256:{'0' * 64}" or "/placeholder." in asset["source"]):
raise ValueError("Replace placeholder media with a real, checksummed asset before review.")
_validate_relations(asset.get("relations", {}))
review = validate_media_review(asset["review"], require_approved=require_review)
clean = deepcopy(asset)
clean["schemaVersion"] = MEDIA_SCHEMA_VERSION
clean["review"] = review
clean["relations"] = deepcopy(asset.get("relations", {}))
return clean


def validate_media_review(review, require_approved=True):
if not isinstance(review, dict):
raise ValueError("Media review must be an object.")
clean = {key: review.get(key) is True for key in REVIEW_KEYS}
if require_approved and not all(clean.values()):
raise ValueError("Content, research, and accessibility review are required.")
decision = review.get("decision", "approved" if all(clean.values()) else "pending")
if decision not in {"pending", "approved", "changes_requested"}:
raise ValueError("Media review decision is invalid.")
note = review.get("note", "")
if not isinstance(note, str) or len(note) > 4000:
raise ValueError("Media review note is too long.")
return {**clean, "decision": decision, "note": note.strip()}


def media_review_result_status(decision):
return "in_review" if decision == "approved" else "draft"


def build_public_media_manifest(records):
latest = {}
for record in records:
asset = record.get("asset") or {}
identity = (asset.get("id"), asset.get("locale"))
version = asset.get("version", 0)
if identity[0] and identity[1] and version > latest.get(identity, {}).get("version", 0):
latest[identity] = asset
assets = []
for asset in latest.values():
public = deepcopy(asset)
public["review"] = {key: public.get("review", {}).get(key) is True for key in REVIEW_KEYS}
assets.append(public)
assets.sort(key=lambda item: (item["id"], item["locale"]))
return {"schemaVersion": MEDIA_SCHEMA_VERSION, "assets": assets}


def _validate_rights(rights, require_complete=True):
if not isinstance(rights, dict):
raise ValueError("Media rights metadata must be an object.")
if require_complete and (not _text(rights.get("owner"), 300) or not _text(rights.get("license"), 300)):
raise ValueError("Media asset rights and license are required.")
access = asset["accessibility"]
if rights.get("sourceUrl") and not _safe_source(rights["sourceUrl"], local_allowed=False):
raise ValueError("Media rights source URL is invalid.")
if rights.get("attribution") is not None and not _optional_text(rights.get("attribution"), 1000):
raise ValueError("Media attribution is invalid.")


def _validate_accessibility(kind, access, require_complete=True):
if not isinstance(access, dict):
raise ValueError("Media accessibility metadata is required.")
if asset["kind"] == "image" and not _text(access.get("alternative"), 2000):
if require_complete and kind == "image" and not _text(access.get("alternative"), 2000):
raise ValueError("Images need alternative text.")
if asset["kind"] in {"audio", "video"} and not _text(access.get("transcript"), 12000):
if require_complete and kind in {"audio", "video"} and not _text(access.get("transcript"), 12000):
raise ValueError("Audio and video need a transcript.")
if asset["kind"] == "video" and not _text(access.get("captions"), 2000):
raise ValueError("Video needs captions.")
review = asset["review"]
if not isinstance(review, dict) or not all(review.get(key) is True for key in ("content_checked", "research_checked", "accessibility_checked")):
raise ValueError("Content, research, and accessibility review are required.")
return deepcopy(asset)
if require_complete and kind == "video" and not _safe_source(access.get("captions")):
raise ValueError("Video needs a safe captions source.")
if access.get("longDescription") is not None and not _optional_text(access.get("longDescription"), 12000):
raise ValueError("Media long description is invalid.")


def _validate_relations(relations):
if not isinstance(relations, dict):
raise ValueError("Media relationships must be an object.")
for key in ("replaces", "localizationOf"):
reference = relations.get(key)
if reference is None:
continue
if not isinstance(reference, dict) or not _text(reference.get("id"), 120) or not isinstance(reference.get("version"), int) or reference["version"] < 1 or not _text(reference.get("locale"), 20):
raise ValueError(f"Media relationship {key} is invalid.")


def _safe_source(value, local_allowed=True):
if not _text(value, 2000):
return False
if local_allowed and value.startswith("/") and not value.startswith("//"):
return True
parsed = urlparse(value)
return parsed.scheme == "https" and bool(parsed.netloc)


def _optional_text(value, maximum):
return value is None or value == "" or _text(value, maximum)


def _text(value, maximum):
Expand Down
Loading
Loading