Skip to content

Commit cd2f6e9

Browse files
HR-codingclaude
andcommitted
feat(ops): Docker + CI/CD + tool-schema gate (Phase 5)
- Multi-stage Dockerfile (app/worker), mongodb-mcp/Dockerfile (HTTP, destructive tools disabled), docker-compose.yml (app + worker + postgres + redis + mongodb-mcp on a private network; Atlas external), .dockerignore. - .github/workflows/ci.yml enforces isolation: PR/push -> lint + tool-schema gate + mocked tests (never deploys); main -> staging; release/dispatch -> prod behind a GitHub Environment approval. - Tool-schema compatibility gate: tool_schema_gate.py derives a contract from ALL_TOOLS and diffs vs tool_schema.json baseline; scripts/check_tool_schema.py CLI fails CI on breaking changes (removed/renamed param, new required param, removed tool). +8 tests. Suite 154/154. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e868c6d commit cd2f6e9

10 files changed

Lines changed: 524 additions & 1 deletion

File tree

.dockerignore

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
.git
2+
.github
3+
**/__pycache__
4+
*.pyc
5+
.pytest_cache
6+
.ruff_cache
7+
venv
8+
.venv
9+
.env
10+
env.txt
11+
credentials.json
12+
client_secret.json
13+
token.json
14+
docs
15+
*.md
16+
mongodb-mcp

.github/workflows/ci.yml

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
name: CI/CD
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches: [main]
7+
release:
8+
types: [published]
9+
workflow_dispatch:
10+
11+
jobs:
12+
# (1) PRs + any push: lint + tool-schema gate + fully-mocked tests. NEVER deploys.
13+
test:
14+
runs-on: ubuntu-latest
15+
steps:
16+
- uses: actions/checkout@v4
17+
- uses: actions/setup-python@v5
18+
with:
19+
python-version: "3.12"
20+
cache: pip
21+
- run: pip install -r requirements.txt
22+
- run: pip install ruff pytest
23+
- name: Lint
24+
run: ruff check app
25+
- name: Tool-schema compatibility gate (blocks breaking tool-contract changes)
26+
run: python scripts/check_tool_schema.py
27+
- name: Tests (all MongoDB + Google calls mocked)
28+
run: pytest app/tests -q
29+
30+
# (2) Merge to main -> auto-deploy to Staging.
31+
deploy-staging:
32+
needs: test
33+
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
34+
runs-on: ubuntu-latest
35+
environment: staging
36+
steps:
37+
- uses: actions/checkout@v4
38+
- name: Build & deploy to staging
39+
run: |
40+
echo "Build images, push to registry, deploy to Cloud Run (staging),"
41+
echo "and update the Vertex AI Agent Engine staging agent."
42+
# gcloud auth + builds submit + run deploy + python -m app.orchestrator.agent_engine
43+
44+
# (3) GitHub Release OR manual dispatch -> Production, behind manual approval.
45+
deploy-production:
46+
needs: test
47+
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
48+
runs-on: ubuntu-latest
49+
environment: production # configure a required-reviewers protection rule on this env
50+
steps:
51+
- uses: actions/checkout@v4
52+
- name: Build & deploy to production
53+
run: |
54+
echo "Deploy to Cloud Run (prod) + Vertex AI Agent Engine (prod)."
55+
# gcloud builds submit + run deploy + agent_engine deploy

Dockerfile

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# syntax=docker/dockerfile:1
2+
# Multi-stage build for the TaxAssist app (API + worker share this image).
3+
FROM python:3.12-slim AS builder
4+
WORKDIR /app
5+
COPY requirements.txt .
6+
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
7+
8+
FROM python:3.12-slim
9+
WORKDIR /app
10+
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
11+
COPY --from=builder /install /usr/local
12+
COPY app ./app
13+
EXPOSE 8080
14+
# API server. The worker runs the same image with:
15+
# command: rq worker -u $REDIS_URL tax-agent
16+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]

app/orchestrator/tool_schema.json

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
{
2+
"apply_extraction_tool": {
3+
"params": {
4+
"extraction_result": {
5+
"annotation": "<class 'dict'>",
6+
"required": true
7+
},
8+
"user_id": {
9+
"annotation": "<class 'str'>",
10+
"required": true
11+
}
12+
}
13+
},
14+
"ask_user_via_email_tool": {
15+
"params": {
16+
"question": {
17+
"annotation": "<class 'str'>",
18+
"required": true
19+
},
20+
"subject": {
21+
"annotation": "<class 'str'>",
22+
"required": false
23+
},
24+
"timeout_seconds": {
25+
"annotation": "<class 'int'>",
26+
"required": false
27+
}
28+
}
29+
},
30+
"calculate_itr1_tax_tool": {
31+
"params": {
32+
"advance_tax": {
33+
"annotation": "<class 'float'>",
34+
"required": false
35+
},
36+
"deduction_80c": {
37+
"annotation": "<class 'float'>",
38+
"required": false
39+
},
40+
"deduction_80ccd1b": {
41+
"annotation": "<class 'float'>",
42+
"required": false
43+
},
44+
"deduction_80d": {
45+
"annotation": "<class 'float'>",
46+
"required": false
47+
},
48+
"dividend_income": {
49+
"annotation": "<class 'float'>",
50+
"required": false
51+
},
52+
"fd_interest": {
53+
"annotation": "<class 'float'>",
54+
"required": false
55+
},
56+
"gross_salary": {
57+
"annotation": "<class 'float'>",
58+
"required": true
59+
},
60+
"savings_interest": {
61+
"annotation": "<class 'float'>",
62+
"required": false
63+
},
64+
"tax_regime": {
65+
"annotation": "<class 'str'>",
66+
"required": false
67+
},
68+
"tds_salary": {
69+
"annotation": "<class 'float'>",
70+
"required": false
71+
}
72+
}
73+
},
74+
"calculate_itr2_tax_tool": {
75+
"params": {
76+
"itr2_data": {
77+
"annotation": "<class 'dict'>",
78+
"required": true
79+
}
80+
}
81+
},
82+
"check_state_tool": {
83+
"params": {
84+
"user_id": {
85+
"annotation": "<class 'str'>",
86+
"required": true
87+
}
88+
}
89+
},
90+
"create_tax_reminder_tool": {
91+
"params": {}
92+
},
93+
"export_findings_to_sheet_tool": {
94+
"params": {
95+
"extraction_result": {
96+
"annotation": "<class 'dict'>",
97+
"required": true
98+
},
99+
"tax_summary": {
100+
"annotation": "<class 'dict'>",
101+
"required": false
102+
}
103+
}
104+
},
105+
"get_document_tool": {
106+
"params": {
107+
"file_name": {
108+
"annotation": "<class 'str'>",
109+
"required": true
110+
}
111+
}
112+
},
113+
"process_document_tool": {
114+
"params": {
115+
"document_data": {
116+
"annotation": "<class 'dict'>",
117+
"required": true
118+
},
119+
"source_doc_id": {
120+
"annotation": "<class 'str'>",
121+
"required": false
122+
}
123+
}
124+
},
125+
"read_unvouched_transactions_tool": {
126+
"params": {
127+
"spreadsheet_id": {
128+
"annotation": "<class 'str'>",
129+
"required": false
130+
}
131+
}
132+
},
133+
"retrieve_tax_rules_tool": {
134+
"params": {
135+
"itr_type": {
136+
"annotation": "<class 'str'>",
137+
"required": false
138+
},
139+
"regime": {
140+
"annotation": "<class 'str'>",
141+
"required": false
142+
}
143+
}
144+
},
145+
"send_clarification_email_tool": {
146+
"params": {
147+
"body": {
148+
"annotation": "<class 'str'>",
149+
"required": true
150+
},
151+
"subject": {
152+
"annotation": "<class 'str'>",
153+
"required": true
154+
},
155+
"to_email": {
156+
"annotation": "<class 'str'>",
157+
"required": true
158+
}
159+
}
160+
},
161+
"update_verified_transaction_tool": {
162+
"params": {
163+
"row_index": {
164+
"annotation": "<class 'int'>",
165+
"required": true
166+
},
167+
"spreadsheet_id": {
168+
"annotation": "<class 'str'>",
169+
"required": false
170+
},
171+
"verified_amount": {
172+
"annotation": "<class 'float'>",
173+
"required": true
174+
}
175+
}
176+
},
177+
"write_state_tool": {
178+
"params": {
179+
"updates": {
180+
"annotation": "<class 'dict'>",
181+
"required": true
182+
},
183+
"user_id": {
184+
"annotation": "<class 'str'>",
185+
"required": true
186+
}
187+
}
188+
},
189+
"write_unvouched_transaction_tool": {
190+
"params": {
191+
"amount": {
192+
"annotation": "<class 'float'>",
193+
"required": true
194+
},
195+
"date": {
196+
"annotation": "<class 'str'>",
197+
"required": true
198+
},
199+
"description": {
200+
"annotation": "<class 'str'>",
201+
"required": true
202+
},
203+
"source": {
204+
"annotation": "<class 'str'>",
205+
"required": false
206+
},
207+
"spreadsheet_id": {
208+
"annotation": "<class 'str'>",
209+
"required": false
210+
},
211+
"transaction_type": {
212+
"annotation": "<class 'str'>",
213+
"required": true
214+
}
215+
}
216+
}
217+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""
2+
Tool-schema compatibility gate (CI).
3+
4+
The agent's tool signatures are a contract: renaming/removing a parameter, or
5+
adding a required one, silently breaks the agent. This derives a JSON schema from
6+
ALL_TOOLS and diffs it against a committed baseline; the CI job fails on any
7+
*breaking* change (additive changes — new tools, new optional params — are fine).
8+
9+
CLI lives in scripts/check_tool_schema.py.
10+
"""
11+
import os
12+
import json
13+
import inspect
14+
15+
BASELINE_PATH = os.path.join(os.path.dirname(__file__), "tool_schema.json")
16+
17+
18+
def current_schema() -> dict:
19+
"""Derive {tool: {params: {name: {required, annotation}}}} from ALL_TOOLS."""
20+
from app.orchestrator.tools import ALL_TOOLS
21+
schema = {}
22+
for fn in ALL_TOOLS:
23+
params = {}
24+
for name, p in inspect.signature(fn).parameters.items():
25+
params[name] = {
26+
"required": p.default is inspect.Parameter.empty,
27+
"annotation": (str(p.annotation)
28+
if p.annotation is not inspect.Parameter.empty else "Any"),
29+
}
30+
schema[fn.__name__] = {"params": params}
31+
return schema
32+
33+
34+
def diff(baseline: dict, current: dict) -> list:
35+
"""Return a list of BREAKING changes (empty == compatible)."""
36+
breaking = []
37+
for tool, b in baseline.items():
38+
if tool not in current:
39+
breaking.append(f"removed tool: {tool}")
40+
continue
41+
c = current[tool]
42+
for pname, pb in b["params"].items():
43+
if pname not in c["params"]:
44+
breaking.append(f"{tool}: removed/renamed param '{pname}'")
45+
continue
46+
pc = c["params"][pname]
47+
if pc["required"] and not pb["required"]:
48+
breaking.append(f"{tool}: param '{pname}' became required")
49+
if pc.get("annotation") != pb.get("annotation"):
50+
breaking.append(
51+
f"{tool}: param '{pname}' type changed "
52+
f"{pb.get('annotation')} -> {pc.get('annotation')}")
53+
for pname, pc in c["params"].items():
54+
if pname not in b["params"] and pc["required"]:
55+
breaking.append(f"{tool}: new REQUIRED param '{pname}'")
56+
return breaking
57+
58+
59+
def load_baseline() -> dict:
60+
with open(BASELINE_PATH, encoding="utf-8") as f:
61+
return json.load(f)
62+
63+
64+
def save_baseline(schema: dict = None):
65+
with open(BASELINE_PATH, "w", encoding="utf-8") as f:
66+
json.dump(schema or current_schema(), f, indent=2, sort_keys=True)

0 commit comments

Comments
 (0)