diff --git a/README.md b/README.md index 7452de2..d44cde2 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ See the [product brief](docs/product-brief.md) for policy context, MVP workflow StayLong is a consent-governed, event-driven coordination layer for older Australians living alone. It turns a home-living concern into an accountable, assessment-ready plan, coordinates approved next steps, and follows up until every approved action is complete. The older person can work independently or invite an authorised supporter for a specific task. -The public-sandbox runtime can pass concern text through a Vertex AI-hosted Gemma redaction guard before persistence or tool actions. Gemini 3.5+ remains the primary ADK coordinator; Gemma returns only a strict privacy contract and cannot change safety, consent or approval transitions. +The public-sandbox runtime passes concern text through Vertex Model Garden MaaS `gemma-4-26b-a4b-it-maas` before persistence or tool actions. Gemini 3.6 Flash remains the primary ADK coordinator; Gemma returns only a strict privacy contract and cannot change safety, consent or approval transitions. If the privacy guard is unavailable or returns invalid output, the workflow fails closed without persisting the concern or starting a plan. ## Public demonstration URL diff --git a/docs/architecture.md b/docs/architecture.md index 83d84a3..64456eb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -26,8 +26,8 @@ flowchart LR R --> ADK["Google ADK intake / coordinator"] API --> O["Google Calendar OAuth routes"] end - ADK --> V["Vertex AI Gemini 3.5+"] - API --> G["Vertex AI Gemma privacy guard"] + ADK --> V["Vertex AI Gemini 3.6 Flash"] + API --> G["Vertex Model Garden MaaS\nGemma 4 privacy guard"] ADK --> F[("Firestore case state")] ADK --> Q["Cloud Tasks / Pub/Sub"] Q --> ADK @@ -52,8 +52,8 @@ The demo seed is [`fixtures/demo/seeded-household.json`](../fixtures/demo/seeded |---|---| | Cloud Run web/API service | Authenticated web UI, API, webhook receiver and ADK entry point. | | Google ADK | Plans and executes a bounded workflow through typed tools. | -| Vertex AI Gemini | Extracts structured concerns, drafts plain-language summaries and proposes next permitted actions. | -| Vertex AI Gemma privacy guard | Detects and redacts unnecessary PII before concern text is persisted or reaches an action boundary; it cannot make workflow or safety decisions. | +| Vertex AI Gemini 3.6 Flash | Extracts structured concerns, drafts plain-language summaries and proposes next permitted actions. | +| Vertex Model Garden MaaS Gemma 4 privacy guard | Uses request-based `gemma-4-26b-a4b-it-maas` to redact unnecessary PII before concern text is persisted or reaches an action boundary; it cannot make workflow or safety decisions. | | Firestore | Stores household consent, concern records, task state, approvals, action history and idempotency keys. | | Cloud Tasks | Schedules due-date checks, reminder retries and escalation work. | | Pub/Sub | Carries event notifications such as `concern.created`, `approval.granted`, `task.overdue` and `assessment.outcome.recorded`. | @@ -63,13 +63,14 @@ The demo seed is [`fixtures/demo/seeded-household.json`](../fixtures/demo/seeded ## Core event flow 1. An older person living alone creates a concern independently, or an authorised supporter creates one with the person's permission. -2. The API performs deterministic red-flag screening before invoking Gemini. -3. The intake agent produces a typed concern summary and lists missing facts. -4. The coordinator creates only allowed draft tasks, appointments or messages. -5. A human approves each external side effect. -6. The action tool executes once, records an idempotency key, and emits an event. -7. A scheduled worker detects overdue work and escalates according to household rules. -8. An assessment outcome moves the case to the next workflow stage; it never creates a clinical prescription or funding decision. +2. The API performs deterministic red-flag screening before invoking any model. +3. For a non-emergency concern, Gemma redacts unnecessary PII. An unavailable or invalid privacy response fails closed before persistence or planning. +4. The intake agent produces a typed concern summary and lists missing facts. +5. The coordinator creates only allowed draft tasks, appointments or messages. +6. A human approves each external side effect. +7. The action tool executes once, records an idempotency key, and emits an event. +8. A scheduled worker detects overdue work and escalates according to household rules. +9. An assessment outcome moves the case to the next workflow stage; it never creates a clinical prescription or funding decision. ## Workflow integrity @@ -81,7 +82,7 @@ The demo seed is [`fixtures/demo/seeded-household.json`](../fixtures/demo/seeded The design rationale and MVP priorities are recorded in [training-informed improvements](training-informed-improvements.md). -Gemma is enabled with `STAYLONG_GEMMA_ENABLED=true` in the sandbox runtime. Its response is schema-validated (`redacted_text` plus `detected_categories`); malformed or empty output is rejected before the workflow continues. The privacy layer is separate from deterministic emergency routing and the Gemini/ADK planning agent. +Gemma is enabled with `STAYLONG_GEMMA_ENABLED=true` in the sandbox runtime and uses the request-based Vertex Model Garden MaaS model `gemma-4-26b-a4b-it-maas` at `global`; no dedicated GPU endpoint is provisioned. Its response is schema-validated (`redacted_text` plus `detected_categories`); malformed, empty or unavailable output returns a safe retry response before the workflow persists a concern or starts the Gemini/ADK planning path. The privacy layer is separate from deterministic emergency routing and the Gemini/ADK planning agent. ## Security and privacy boundaries diff --git a/docs/devpost-submission-readiness.md b/docs/devpost-submission-readiness.md index fe8c995..c28be57 100644 --- a/docs/devpost-submission-readiness.md +++ b/docs/devpost-submission-readiness.md @@ -38,7 +38,7 @@ This checklist is based on the [official Devpost overview](https://allthingsagen ### Additional Google AI model -- [x] Integrate Vertex AI Gemma as a real PII redaction guard before persistence or tool actions. +- [ ] Deploy Vertex Model Garden MaaS `gemma-4-26b-a4b-it-maas` as the real PII redaction guard before persistence or tool actions. - [x] Validate its strict response contract and reject malformed/empty output. - [ ] Add model/version, invocation path, tests and cost boundary to the final evidence and demo. @@ -50,4 +50,3 @@ This checklist is based on the [official Devpost overview](https://allthingsagen - [ ] Architecture diagram, public demo video, technical article and social-post URLs. - [ ] Gemma implementation and test evidence. - [ ] Final Devpost preview checked before submission. - diff --git a/docs/gemma-privacy.md b/docs/gemma-privacy.md index 3f4822e..c02c985 100644 --- a/docs/gemma-privacy.md +++ b/docs/gemma-privacy.md @@ -1,14 +1,13 @@ # Gemma privacy guard -StayLong uses Vertex AI-hosted `gemma-3-27b-it` as a bounded privacy layer in the public sandbox. Before a concern is persisted or reaches an action boundary, the guard asks Gemma to return exactly: +StayLong uses Vertex Model Garden MaaS `gemma-4-26b-a4b-it-maas` as a bounded privacy layer in the public sandbox. It is a request-based, global model service, so StayLong does not deploy or operate a dedicated GPU endpoint. Before a concern is persisted or reaches an action boundary, the guard asks Gemma to return exactly: ```json {"redacted_text":"...", "detected_categories":["phone"]} ``` -The response is schema-validated. Empty text, unexpected fields, malformed JSON or invalid categories are rejected. Gemma cannot route emergencies, decide eligibility, select providers, grant consent or approve actions; those decisions remain deterministic application policy and human approval. +The response is schema-validated. Empty text, unexpected fields, malformed JSON, invalid categories or an unavailable Gemma service are rejected. In every rejection case, StayLong fails closed: it does not persist the concern, start the Gemini/ADK plan, or execute an action. The public API instead returns a plain-language retry response without exposing model or user-text details. Gemma cannot route emergencies, decide eligibility, select providers, grant consent or approve actions; those decisions remain deterministic application policy and human approval. -Enable the integration with `STAYLONG_GEMMA_ENABLED=true`. The sandbox Terraform component also supplies the Vertex project, `global` location and `GOOGLE_GENAI_USE_VERTEXAI=true`. Local tests inject a fake provider and never call Vertex. - -This integration is an optional All Things Agentic Hackathon bonus contribution. The required Gemini 3.5+/ADK coordinator remains the primary planning model. +Enable the integration with `STAYLONG_GEMMA_ENABLED=true`. The sandbox Terraform component also supplies the exact MaaS model ID, Vertex project, `global` location and `GOOGLE_GENAI_USE_VERTEXAI=true`. Before deployment, an authorised operator must enable the model's API from its Model Garden **API Service** card. Local tests inject a fake provider and never call Vertex. +This integration is an optional All Things Agentic Hackathon bonus contribution. The required Gemini 3.6 Flash/ADK coordinator remains the primary planning model. Evidence must show the Model Garden model ID, a synthetic-data public smoke, the strict response tests and the request-based cost boundary; no real personal information, tokens or prompts are retained in evidence. diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index ef92702..d780616 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -153,6 +153,19 @@ describe('StayLong Continuous Home Path', () => { ) }) + it('turns a chosen example into an editable starting point', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: 'Night-time bathroom' })) + + expect(screen.getByText('You chose: Night-time bathroom')).toBeVisible() + expect(screen.getByText('We’ve added a starting point below — change the words so they sound like you.')).toBeVisible() + expect(screen.getByRole('textbox', { name: 'Describe what is becoming difficult' })).toHaveValue( + 'I’m finding it harder to reach the bathroom safely at night. The hallway is dark and there are no rails near the toilet.', + ) + }) + it('shows an assessment pack and user-controlled action before approval', async () => { const user = userEvent.setup() const fetchMock = stubWorkflowFetches() diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 040bde7..dd490f9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -110,7 +110,6 @@ function App() { setMessage(result ? '' : 'You chose to keep this action for later. You can reconsider whenever you are ready.') } catch (error) { setMessage(error instanceof Error ? error.message : 'Something went wrong.') } finally { setBusy(false); setBusyMessage('') } } - return (
Skip to main content @@ -385,5 +384,3 @@ function resultNextStep(result: ActionResult) { } export default App - - diff --git a/infra/terraform/components/public-sandbox/main.tf b/infra/terraform/components/public-sandbox/main.tf index e25f609..4e2e061 100644 --- a/infra/terraform/components/public-sandbox/main.tf +++ b/infra/terraform/components/public-sandbox/main.tf @@ -149,11 +149,11 @@ resource "google_cloud_run_v2_service" "sandbox" { value = "true" } - # The public sandbox project does not have access to the Gemma publisher - # model, so use an available Vertex model for the same privacy contract. + # Vertex Model Garden MaaS: request-based Gemma privacy redaction without + # a dedicated GPU endpoint. See SAI-72 for the deployment evidence. env { name = "STAYLONG_PRIVACY_MODEL" - value = "gemini-2.5-flash" + value = "gemma-4-26b-a4b-it-maas" } env { diff --git a/src/staylong/api/app.py b/src/staylong/api/app.py index 3d4e9e4..576f004 100644 --- a/src/staylong/api/app.py +++ b/src/staylong/api/app.py @@ -610,12 +610,18 @@ def _now() -> datetime: def _raise_safe_intake_error(error: Exception) -> None: """Map policy refusals to plain language without returning internal details.""" from staylong.agents.intake import MedicalTriageRefusalRequired + from staylong.privacy.gemma import PrivacyGuardError if isinstance(error, MedicalTriageRefusalRequired): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(error), ) from None + if isinstance(error, PrivacyGuardError): + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="StayLong could not safely prepare your plan. Please try again.", + ) from None raise error diff --git a/src/staylong/privacy/gemma.py b/src/staylong/privacy/gemma.py index 13a9bee..58584de 100644 --- a/src/staylong/privacy/gemma.py +++ b/src/staylong/privacy/gemma.py @@ -69,7 +69,7 @@ def __init__( *, project_id: str, location: str = "global", - model_id: str = "gemma-3-27b-it", + model_id: str = "gemma-4-26b-a4b-it-maas", ) -> None: if not project_id.strip(): raise ValueError("project_id must be configured for Gemma.") @@ -106,14 +106,13 @@ def build_vertex_gemma_privacy_guard( ) -> GemmaPrivacyGuard: """Build the privacy guard with the configured Vertex-hosted model. - Gemma remains the default model. Environments that do not have access to - the Gemma publisher model can explicitly select another approved Vertex - model (for example ``gemini-2.5-flash``) via ``STAYLONG_PRIVACY_MODEL``. + Gemma MaaS remains the default model. Deployments may explicitly select a + different approved Gemma model through ``STAYLONG_PRIVACY_MODEL``. """ return GemmaPrivacyGuard( provider=VertexGemmaJsonProvider( project_id=project_id, location=location, - model_id=os.getenv("STAYLONG_PRIVACY_MODEL", "gemma-3-27b-it"), + model_id=os.getenv("STAYLONG_PRIVACY_MODEL", "gemma-4-26b-a4b-it-maas"), ) ) diff --git a/tests/api/test_public_sandbox_api.py b/tests/api/test_public_sandbox_api.py index 1fed1c5..8e15c69 100644 --- a/tests/api/test_public_sandbox_api.py +++ b/tests/api/test_public_sandbox_api.py @@ -13,7 +13,15 @@ from tests.api.test_taskmaster_api import ANSWERS, StaticProvider -def _public_client() -> TestClient: +class FailingPrivacyGuard: + def redact(self, text: str): + from staylong.privacy.gemma import PrivacyGuardError + + del text + raise PrivacyGuardError("privacy provider unavailable") + + +def _public_client(*, privacy_guard: object | None = None) -> TestClient: from staylong.api.app import PublicSandboxConfig workflow = TaskmasterWorkflow( @@ -21,6 +29,7 @@ def _public_client() -> TestClient: repository=InMemoryWorkflowRepository(), event_repository=InMemoryEventRepository(), calendar=CalendarDemoAdapter(), + privacy_guard=privacy_guard, ) return TestClient( create_app( @@ -84,6 +93,20 @@ def test_private_workflow_route_still_requires_bearer_authentication() -> None: assert response.status_code == 401 +def test_public_workflow_fails_closed_when_privacy_guard_is_unavailable() -> None: + """Do not persist or begin a plan if Gemma cannot protect the concern.""" + client = _public_client(privacy_guard=FailingPrivacyGuard()) + response = client.post( + "/v1/public/workflows", + json={"concern": "Please call 0412 345 678 about the dark hallway."}, + ) + + assert response.status_code == 503 + assert response.json() == { + "detail": "StayLong could not safely prepare your plan. Please try again." + } + + def test_public_owner_can_approve_a_sandbox_action() -> None: """A session owner may explicitly approve its own sandbox-only next step.""" browser = _public_client() diff --git a/tests/infra/test_public_sandbox_component.py b/tests/infra/test_public_sandbox_component.py index 7eee996..ba824da 100644 --- a/tests/infra/test_public_sandbox_component.py +++ b/tests/infra/test_public_sandbox_component.py @@ -45,11 +45,11 @@ def test_public_sandbox_enables_vertex_ai_for_gemma_privacy_guard() -> None: assert 'service = "aiplatform.googleapis.com"' in source -def test_public_sandbox_selects_an_available_vertex_privacy_model() -> None: +def test_public_sandbox_selects_the_gemma_maas_privacy_model() -> None: source = (COMPONENT / "main.tf").read_text(encoding="utf-8") assert 'name = "STAYLONG_PRIVACY_MODEL"' in source - assert 'value = "gemini-2.5-flash"' in source + assert 'value = "gemma-4-26b-a4b-it-maas"' in source def test_public_sandbox_outputs_only_url_and_non_sensitive_evidence() -> None: diff --git a/tests/privacy/test_gemma.py b/tests/privacy/test_gemma.py index 9f75c56..77f07fa 100644 --- a/tests/privacy/test_gemma.py +++ b/tests/privacy/test_gemma.py @@ -55,14 +55,14 @@ def test_gemma_rejects_empty_redacted_text() -> None: guard.redact("A concern") -def test_vertex_privacy_guard_uses_explicit_environment_model(monkeypatch) -> None: +def test_vertex_privacy_guard_defaults_to_gemma_maas_model(monkeypatch) -> None: captured: dict[str, str] = {} class RecordingProvider: def __init__(self, *, project_id: str, location: str, model_id: str) -> None: captured.update(project_id=project_id, location=location, model_id=model_id) - monkeypatch.setenv("STAYLONG_PRIVACY_MODEL", "gemini-2.5-flash") + monkeypatch.delenv("STAYLONG_PRIVACY_MODEL", raising=False) monkeypatch.setattr("staylong.privacy.gemma.VertexGemmaJsonProvider", RecordingProvider) build_vertex_gemma_privacy_guard(project_id="stay-long", location="global") @@ -70,5 +70,5 @@ def __init__(self, *, project_id: str, location: str, model_id: str) -> None: assert captured == { "project_id": "stay-long", "location": "global", - "model_id": "gemini-2.5-flash", + "model_id": "gemma-4-26b-a4b-it-maas", }