Unified Cloud Run application and REST API (app/)¶
This directory contains the unified Cloud Run application
(soa-refund-agent-app) serving both web interfaces (/shop and /console)
and the FastAPI backend API that runs the Google Agent Development Kit 2.0 (ADK
2.0) refund workflow.
Directory layout¶
The app/ directory contains the FastAPI entrypoint, dependencies, ADK 2.0
agent package, web frontend views, and unit tests:
app/
├── README.md # FastAPI backend and REST API specification (this file)
├── __init__.py # Package initializer
├── Dockerfile # Multi-stage, non-root container build for FastAPI and UIs
├── requirements.in # Top-level Python dependencies with major-version pins (~=)
├── requirements.txt # Hash-locked Python dependencies (--generate-hashes)
├── main.py # FastAPI server serving /shop, /console, and /api/*
├── agent/ # Google ADK 2.0 refund workflow and deterministic node functions
│ ├── README.md # ADK 2.0 workflow, security guardrails, and BigQuery callbacks
│ ├── __init__.py # Agent package initializer
│ ├── workflow.py # ADK 2.0 Workflow graph and BigQuery lineage callbacks
│ ├── spanner_client.py # Cloud Spanner Graph parameterized ISO GQL queries and mutations
│ ├── bq_lineage.py # BigQuery ActionLineageGraph writer and GQL runner
│ └── catalog_client.py # Knowledge Catalog glossary client with JSON fallback
├── frontend/ # HTML, JS, Tailwind CSS, and Cytoscape.js views for /shop and /console
│ ├── README.md # UI 1 and UI 2 component specifications
│ ├── shop.html # UI 1: Mockup thelook_ecommerce storefront and ADK 2.0 chat widget
│ ├── console.html # UI 2: Data engineer trace feed, Cytoscape.js graph, GQL workbench, and modals
│ └── static/
│ ├── shop.js # Client controller for persona switching, orders, and refund chat
│ ├── console.js # Client controller for Cytoscape.js rendering, 3-tab GQL runner, and modals
│ └── products/ # 8 locally hosted unbranded AI-generated studio product photos (256x256 PNG)
└── tests/ # Python unit tests for workflow logic and FastAPI endpoints
├── __init__.py # Test package initializer
├── test_workflow.py
└── test_api.py
app/requirements.in pins top-level dependencies using ~=:
fastapi~=0.162, uvicorn~=0.54, google-adk~=2.13,
google-genai~=2.33, google-cloud-spanner~=3.51,
google-cloud-bigquery~=3.29, google-cloud-dataplex~=2.6,
httpx~=0.28, and pydantic~=2.12.
Sub-component documentation¶
Each sub-directory provides its own detailed specification:
- ADK 2.0 agent runtime: See agent/README.md for the
RefundSystemOfActionWorkflowspecification (workflow.py,spanner_client.py,bq_lineage.py, andcatalog_client.py). - Dual web interfaces: See frontend/README.md for the
UI 1 (
TheLook Shopping Mall and Chat App,/shop) and UI 2 (Data Engineer Investigation Console,/console) specifications. - Database and lineage schemas: See data/README.md for the Cloud Spanner and BigQuery property graph schemas.
Backend REST API contract (main.py)¶
The FastAPI backend serves both web interfaces and exposes the following JSON API endpoints:
| HTTP method and path | Used by | Request and response contract |
|---|---|---|
GET /api/health |
Cloud Run and Terraform | Awaits the asyncio.Lock-guarded ensure_bootstrapped() lifespan task (which runs seed_spanner_if_empty() so scale-out or cold-start instances never overwrite existing demo orders). Requires Cloud Spanner seeding and the four BigQuery lineage tables to succeed, while treating CREATE OR REPLACE PROPERTY GRAPH Data Definition Language (DDL) errors in on-demand gcpfree projects as non-blocking. Returns {"status": "ok", "spanner_seeded": true, "bq_tables_ready": true, "bq_graph_mode": "gql" \| "sql_fallback", "catalog_ready": true}. |
GET /api/products |
UI 1 (/shop) |
Returns {"products": [{"id": int, "name": str, "brand": str, "category": str, "department": str, "retail_price": float, "distribution_center_name": str, "policy_id": str}]} from Cloud Spanner. |
GET /api/customers |
UI 1 (/shop) |
Returns {"customers": [{"user_id": int, "first_name": str, "last_name": str, "email": str, "customer_tier": "STANDARD" \| "VIP", "prior_refund_count_100d": int, "max_refunds_100d": int}]} for personas 1001 (STANDARD), 2048 (STANDARD), and 3072 (VIP). |
GET /api/customers/{user_id}/orders |
UI 1 (/shop) |
Validates user_id as int and returns {"orders": [{"order_id": int, "status": str, "created_at": str, "delivered_at": str \| null, "returned_at": str \| null, "items": [{"id": int, "product_name": str, "sale_price": float, "status": str, "refund_trace_id": str \| null}]}]}. |
POST /api/chat/refund |
UI 1 (/shop) |
Accepts {"user_id": int, "message": str, "confirmed": bool, "pending_order_id": int \| null}. Binds user_id outside the LLM prompt, generates a UUID trace_id (trace-<uuid>), executes the compiled google.adk.Workflow (refund_system_of_action.adk_workflow) via google.adk.runners.InMemoryRunner (require_confirmation=True), and returns {"trace_id": str, "order_id": int \| null, "resolution_mode": str, "unmatched_product": str \| null, "candidate_orders": list[dict], "pending_confirmation": dict \| null, "reply": str, "decision_status": "APPROVED" \| "DENIED" \| "PENDING_CONFIRMATION" \| "ORDER_LIST" \| "ORDER_INFO" \| "POLICY_INFO", "failure_code": str \| null, "policy_eval": dict, "action_outcome": dict} (see agent/README.md for the policy_eval and action_outcome field schemas). |
POST /api/chat/refund/stream |
UI 1 (/shop) |
Accepts the same RefundChatRequest JSON body ({"user_id": int, "message": str, "confirmed": bool, "pending_order_id": int \| null}) and returns a StreamingResponse (application/x-ndjson) that emits real-time per-node step events ({"type": "step", "step_index": 1..5, "total_steps": 5, "node_name": str, "phase": "start" \| "end", "latency_ms": float \| null, "detail_en": str, "detail_ko": str}) as each of the 5 ADK 2.0 workflow nodes starts and finishes, followed by the final {"type": "result", "data": dict} payload. |
POST /api/demo/reset |
UI 1 and UI 2 | Accepts {}. Calls reset_demo_orders() in data/spanner_seed.py to refresh all three hydration stages with fresh relative UTC timestamps (datetime.now(timezone.utc)) and reset order_id: 90124, order_id: 90588, and order_id: 90910 in Cloud Spanner (orders.status = 'Complete', orders.returned_at = NULL, order_items.status = 'Complete', order_items.returned_at = NULL, order_items.refund_trace_id = NULL), clears pending customer confirmation and conversation memory state (clear_pending_confirmations()), and clears BigQuery soa_action_lineage trace tables (reset_bigquery_lineage()). Returns {"status": "reset", "reset_order_ids": [90124, 90588, 90910], "bigquery_lineage_cleared": bool}. |
GET /api/traces |
UI 2 (/console) |
Queries BigQuery (joining agent_sessions, workflow_nodes on n.node_name = 'evaluate_spanner_policy', policy_evaluations, and agent_actions) to return up to 50 recent refund workflow traces: {"traces": [{"trace_id": str, "customer_id": int, "target_order_id": int \| null, "decision_status": str, "evaluation_result": str, "observed_refunds_100d": int, "max_refunds_100d": int, "latency_ms": float, "started_at": str}]}. |
GET /api/traces/{trace_id}/graph |
UI 2 (/console) |
Queries the four BigQuery trace tables (using parameterized SQL in sql_fallback mode or GRAPH_TABLE in gql mode) and Cloud Spanner (OperationalContextGraph, skipping the target-order traversal when target_order_id is None) for trace_id, links AgentSession to orders (targets_order) and PolicyEvaluation to policies (references_policy), and returns Cytoscape.js elements: {"nodes": [{"data": {"id": str, "label": str, "layer": "r2g" \| "u2g" \| "bq", "properties": dict}}], "edges": [{"data": {"id": str, "source": str, "target": str, "label": str}}]}. |
GET /api/governance |
UI 2 (/console) |
Returns {"catalog_contract": dict, "glossary_terms": list[dict], "policy_document_markdown": str, "u2g_triples": dict} combining the resolved Knowledge Catalog semantic contract, the three governed business glossary term definitions (rolling-100d-refund-count, order-age-days, and customer-loyalty-tier with sql_mapping from data/policies/business_glossary.json), the source return_policy_v1.md Markdown document, and pre-compiled U2G triples (POL-REFUND-STD-V1 with STANDARD and VIP conditions). |
POST /api/query/spanner |
UI 2 (/console) |
Accepts {"query": str}. Strips leading -- and /* ... */ comments (_strip_sql_comments), enforces a read-only GRAPH / SELECT guard, executes the ISO Graph Query Language (GQL) or SQL query inside a Cloud Spanner read-only snapshot (database.snapshot(), timeout = 15s, max_rows = 500), and returns {"columns": [str], "rows": [dict], "latency_ms": float}. |
POST /api/query/bigquery |
UI 2 (/console) |
Accepts {"query": str}. Strips leading -- and /* ... */ comments (_strip_sql_comments), enforces a read-only SELECT / GRAPH_TABLE / WITH statement guard, configures QueryJobConfig(maximum_bytes_billed=100 * 1024 * 1024, job_timeout_ms=15000), caps output at 500 rows, and maps the built-in ActionLineageGraph GRAPH_TABLE preset templates to their parameterized relational SQL JOIN equivalents (supporting both single-trace queries via WHERE s.trace_id = ... / {trace_id: '...'} and all-trace scans ordered by s.started_at DESC LIMIT 50 when trace_id is omitted or commented out) when bq_graph_mode == "sql_fallback". Returns {"columns": [str], "rows": [dict], "execution_mode": "gql" \| "sql_fallback", "latency_ms": float}. |
Runtime environment variables¶
Terraform (main.tf) passes the following environment variables to the Cloud
Run service:
| Environment variable | Default or example | Description |
|---|---|---|
GCP_PROJECT_ID |
${local.gcp_project_id} |
Active Google Cloud project ID. |
GCP_REGION |
us-central1 |
Active deployment region (us-central1, us-west1, us-west4, us-east1, us-east4). |
SPANNER_INSTANCE_ID |
soa-graph-instance |
Cloud Spanner Enterprise instance identifier. |
SPANNER_DATABASE_ID |
thelook-soa-db |
Cloud Spanner database hosting OperationalContextGraph. |
BIGQUERY_DATASET_ID |
soa_action_lineage |
BigQuery dataset hosting ActionLineageGraph. |
SPANNER_CONNECTION_ID |
${local.gcp_project_id}.${var.gcp_region}.spanner-data-boost |
BigQuery Cloud Spanner Data Boost connection ID used in EXTERNAL_QUERY. |
DATAPLEX_GLOSSARY_ID |
soa-business-glossary |
Knowledge Catalog Business Glossary ID in location = "global". |
DATAPLEX_ASPECT_TYPE_ID |
refund-metric-contract |
Knowledge Catalog Aspect Type ID in location = "global". |
GOOGLE_GENAI_USE_VERTEXAI |
"True" |
Configures google-adk and google-genai to authenticate using Vertex AI Application Default Credentials (ADC) (soa-app-sa). |
GOOGLE_CLOUD_PROJECT |
${local.gcp_project_id} |
Project ID read by google-adk and google-genai. |
GOOGLE_CLOUD_LOCATION |
global |
Vertex AI location read by google-adk and google-genai. |
ADK_MODEL_NAME |
gemini-3-flash-preview |
Vertex AI Gemini model configured for IntentAgent and ResponseAgent (_generate_gemini_text_fast with ThinkingLevel.MINIMAL). |
VERTEX_LLM_TIMEOUT_MS |
4000 |
Per-call HTTP timeout in milliseconds configured on google.genai.Client (HttpOptions with HttpRetryOptions(attempts=1)). |
MAX_REFUNDS_100D_LIMIT |
10 |
Standard tier refund policy threshold passed from var.max_refunds_100d_limit to data/spanner_seed.py. |
VIP_MAX_REFUNDS_100D_LIMIT |
20 |
VIP tier refund policy threshold passed from var.vip_max_refunds_100d_limit to data/spanner_seed.py. |
APP_BUILD_HASH |
"${module.cli.wait}-${sha256(jsonencode(local.app_build_triggers))}" |
Composite SHA-256 build hash of backend, frontend, policy, and product image files used to trigger a new Cloud Run revision whenever application assets change. |
API security, parameterization, and container standards¶
The backend enforces four security and container engineering invariants:
- Parameterized query binding: All database queries in
spanner_client.pyandbq_lineage.pycast non-null identifiers to Python integers (int(user_id)andint(order_id)whenorder_id is not None) or validatedtrace_idstrings matching^trace-[0-9a-fA-F-]+$and bind them using Cloud Spanner typed parameters (params={"user_id": ...},param_types={"user_id": param_types.INT64}) and BigQueryScalarQueryParameterobjects without string interpolation. - In-memory SQL/GQL template and Spanner session caching: The backend loads
multi-line SQL and ISO GQL templates into memory at startup so HTTP requests
do not read template files from disk, and
get_spanner_database()indata/spanner_seed.pycaches the(project_id, instance_id, database_id)Cloud SpannerDatabaseinstance in_SPANNER_DB_CACHEacross requests. - Sanitized error responses and structured logging: All modules use Python's
standard
logginglibrary (logger.info(),logger.warning(),logger.error()). FastAPI exception handlers catch Google Cloud client and validation exceptions, log details server-side, and return user-safe JSON error messages without exposing stack traces. - Reproducible multi-stage non-root container (
Dockerfile):scripts/qwiklab.shstagesdata/intoapp/dataand buildsapp/Dockerfileusingapp/as the Docker build context (matchingkokoro/ci-tasks/04-unit-tests) so the image includes bothapp/anddata/. Developers compilerequirements.txtfromrequirements.in(~=) usinguv pip compile --generate-hashes, andapp/Dockerfileinstalls those hash-locked packages inside a multi-stagepython:3.12-slimimage withpip install --no-cache-dir --require-hashes -r requirements.txtand runs the server as a non-root user (USER 1000).