Add CI workflow and apply ruff formatting

This commit is contained in:
Adam Moussa 2026-05-08 15:41:27 -04:00
parent 718977831c
commit 9cb5f416c1
12 changed files with 233 additions and 134 deletions

13
.github/workflows/ci.yaml vendored Normal file
View file

@ -0,0 +1,13 @@
name: CI
on:
pull_request:
branches: [main]
jobs:
python:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@main
with:
source-dirs: "src"
run-sam-validate: false
typescript:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@main

View file

@ -4,6 +4,11 @@ from decimal import Decimal
import boto3
from conversation.tools import TOOLS, dispatch_tool, set_tool_context
from shared.dynamo import get_conversation, save_conversation
from shared.secrets import get_slack_token
from shared.slack import update_message, post_message
class _DecimalEncoder(json.JSONEncoder):
def default(self, o):
@ -11,10 +16,6 @@ class _DecimalEncoder(json.JSONEncoder):
return int(o) if o == int(o) else float(o)
return super().default(o)
from shared.dynamo import get_conversation, save_conversation
from shared.secrets import get_slack_token
from shared.slack import update_message, post_message
from conversation.tools import TOOLS, dispatch_tool, set_tool_context
logger = logging.getLogger()
logger.setLevel(logging.INFO)
@ -99,15 +100,21 @@ def lambda_handler(event, context):
if placeholder_ts:
update_message(channel, placeholder_ts, final_text, token)
elif coordination:
post_message(channel, final_text, token,
thread_ts=coordination["coord_thread_ts"])
post_message(
channel, final_text, token, thread_ts=coordination["coord_thread_ts"]
)
elif thread_ts:
post_message(channel, final_text, token, thread_ts=thread_ts)
else:
post_message(channel, final_text, token)
logger.info("Conversation complete: channel=%s, thread=%s, turns=%d, is_dm=%s",
channel, thread_ts, len(messages), is_dm)
logger.info(
"Conversation complete: channel=%s, thread=%s, turns=%d, is_dm=%s",
channel,
thread_ts,
len(messages),
is_dm,
)
except Exception:
logger.exception("Conversation failed for thread %s", thread_ts)
try:
@ -143,14 +150,20 @@ def _run_conversation(messages, system):
for block in assistant_message["content"]:
tool_use = block.get("toolUse")
if tool_use:
logger.info("Tool call: %s(%s)", tool_use["name"], json.dumps(tool_use["input"]))
logger.info(
"Tool call: %s(%s)", tool_use["name"], json.dumps(tool_use["input"])
)
result = dispatch_tool(tool_use["name"], tool_use["input"])
tool_results.append({
tool_results.append(
{
"toolResult": {
"toolUseId": tool_use["toolUseId"],
"content": [{"text": json.dumps(result, cls=_DecimalEncoder)}],
"content": [
{"text": json.dumps(result, cls=_DecimalEncoder)}
],
}
})
}
)
messages.append({"role": "user", "content": tool_results})
return assistant_message
@ -161,4 +174,8 @@ def _extract_text(message):
for block in message.get("content", []):
if "text" in block:
parts.append(block["text"])
return "\n".join(parts) if parts else "I processed your request but have nothing to report."
return (
"\n".join(parts)
if parts
else "I processed your request but have nothing to report."
)

View file

@ -6,8 +6,13 @@ from datetime import datetime, timezone
import boto3
from shared.dynamo import (
get_todays_messages, get_unanswered_threads, save_coordination,
create_task, get_tasks, update_task_status, delete_task,
get_todays_messages,
get_unanswered_threads,
save_coordination,
create_task,
get_tasks,
update_task_status,
delete_task,
)
from shared.gmail import get_authenticated_service, fetch_thread
from shared.secrets import get_config, get_slack_token
@ -536,18 +541,29 @@ def _handle_search_inbox(inputs):
query = inputs["query"]
max_results = inputs.get("max_results", 10)
resp = service.users().messages().list(
userId="me", q=query, maxResults=max_results
).execute()
resp = (
service.users()
.messages()
.list(userId="me", q=query, maxResults=max_results)
.execute()
)
messages = []
for msg_meta in resp.get("messages", []):
msg = service.users().messages().get(
userId="me", id=msg_meta["id"], format="metadata",
msg = (
service.users()
.messages()
.get(
userId="me",
id=msg_meta["id"],
format="metadata",
metadataHeaders=["From", "To", "Subject", "Date"],
).execute()
)
.execute()
)
headers = {h["name"]: h["value"] for h in msg["payload"]["headers"]}
messages.append({
messages.append(
{
"id": msg["id"],
"thread_id": msg["threadId"],
"from": headers.get("From", ""),
@ -555,7 +571,8 @@ def _handle_search_inbox(inputs):
"subject": headers.get("Subject", ""),
"date": headers.get("Date", ""),
"snippet": msg.get("snippet", ""),
})
}
)
return {"query": query, "count": len(messages), "messages": messages}
@ -668,8 +685,10 @@ def _handle_get_calendar_events(inputs):
{
"id": e.get("id", ""),
"summary": e.get("summary", "(no title)"),
"start": e.get("start", {}).get("dateTime") or e.get("start", {}).get("date", ""),
"end": e.get("end", {}).get("dateTime") or e.get("end", {}).get("date", ""),
"start": e.get("start", {}).get("dateTime")
or e.get("start", {}).get("date", ""),
"end": e.get("end", {}).get("dateTime")
or e.get("end", {}).get("date", ""),
"attendees": [a.get("email", "") for a in e.get("attendees", [])],
"location": e.get("location", ""),
"status": e.get("status", ""),

View file

@ -28,7 +28,8 @@ def lambda_handler(event, context):
thread_msgs = fetch_thread(service, thread_id)
adam_replied_after = any(
m["from_email"] == config["adam_email"]
and m["internal_date"] > _iso_to_epoch_ms(thread.get("unanswered_since", ""))
and m["internal_date"]
> _iso_to_epoch_ms(thread.get("unanswered_since", ""))
for m in thread_msgs
)
if adam_replied_after:
@ -42,7 +43,9 @@ def lambda_handler(event, context):
normal_count = sum(1 for m in messages if m.get("classification") == "NORMAL")
low_count = sum(1 for m in messages if m.get("classification") == "LOW")
blocks = build_daily_digest(high_items, bypassed, unanswered, normal_count, low_count)
blocks = build_daily_digest(
high_items, bypassed, unanswered, normal_count, low_count
)
send_dm(
config["adam_slack_user_id"],
blocks,

View file

@ -2,14 +2,26 @@ import logging
from datetime import datetime, timezone
from shared.secrets import get_config, get_slack_token
from shared.gmail import get_authenticated_service, fetch_history, fetch_message, initial_sync
from shared.dynamo import message_exists, save_message, save_thread_state, get_sync_metadata, save_sync_metadata
from shared.gmail import (
get_authenticated_service,
fetch_history,
fetch_message,
initial_sync,
)
from shared.dynamo import (
message_exists,
save_message,
save_thread_state,
get_sync_metadata,
save_sync_metadata,
)
from shared.classify import classify_email, check_bypassed_workorder
from shared.slack import send_dm, build_high_priority_alert
logger = logging.getLogger()
logger.setLevel(logging.INFO)
def lambda_handler(event, context):
config = get_config()
service = get_authenticated_service()
@ -47,19 +59,25 @@ def lambda_handler(event, context):
slack_ts = ""
if classification["classification"] == "HIGH":
blocks = build_high_priority_alert({
blocks = build_high_priority_alert(
{
**msg,
"classification_reason": classification["reason"],
})
slack_ts = send_dm(
}
)
slack_ts = (
send_dm(
config["adam_slack_user_id"],
blocks,
f"HIGH: {msg['subject']}",
get_slack_token(),
) or ""
)
or ""
)
results["high"] += 1
save_message({
save_message(
{
**msg,
"classification": classification["classification"],
"classification_reason": classification["reason"],
@ -67,7 +85,8 @@ def lambda_handler(event, context):
"classified_date": today,
"notified": bool(slack_ts),
"slack_ts": slack_ts,
})
}
)
save_thread_state(msg["thread_id"], msg, config["adam_email"])
results["processed"] += 1

View file

@ -13,6 +13,7 @@ def lambda_handler(event, context):
token = get_slack_token()
import requests
resp = requests.post(
"https://slack.com/api/conversations.open",
headers={"Authorization": f"Bearer {token}"},

View file

@ -38,14 +38,18 @@ def get_calendar_service():
def list_events(service, time_min, time_max, max_results=10):
result = service.events().list(
result = (
service.events()
.list(
calendarId="primary",
timeMin=time_min,
timeMax=time_max,
maxResults=max_results,
singleEvents=True,
orderBy="startTime",
).execute()
)
.execute()
)
return result.get("items", [])

View file

@ -62,7 +62,7 @@ def classify_email(message, vip_senders, vip_domains):
inferenceConfig={"temperature": 0.0, "maxTokens": 256},
)
raw = resp["output"]["message"]["content"][0]["text"]
match = re.search(r'\{[^{}]*\}', raw)
match = re.search(r"\{[^{}]*\}", raw)
result = json.loads(match.group()) if match else json.loads(raw)
return {
"classification": result.get("classification", "NORMAL"),
@ -75,7 +75,6 @@ def classify_email(message, vip_senders, vip_domains):
def check_bypassed_workorder(message, work_order_addresses):
all_recipients = [
addr.lower()
for addr in message["to_emails"] + message["cc_emails"]
addr.lower() for addr in message["to_emails"] + message["cc_emails"]
]
return not any(wo in all_recipients for wo in work_order_addresses)

View file

@ -101,9 +101,7 @@ def save_thread_state(thread_id, message, adam_email):
def get_sync_metadata():
resp = _get_table().get_item(
Key={"pk": "META#sync", "sk": "META"}
)
resp = _get_table().get_item(Key={"pk": "META#sync", "sk": "META"})
return resp.get("Item")
@ -139,25 +137,26 @@ def get_todays_messages(date_str):
def get_conversation(thread_ts):
resp = _get_table().get_item(
Key={"pk": f"CONV#{thread_ts}", "sk": "CONV"}
)
resp = _get_table().get_item(Key={"pk": f"CONV#{thread_ts}", "sk": "CONV"})
item = resp.get("Item")
return item["messages"] if item else []
def save_conversation(thread_ts, messages):
_get_table().put_item(Item={
_get_table().put_item(
Item={
"pk": f"CONV#{thread_ts}",
"sk": "CONV",
"messages": messages,
"updated_at": datetime.now(timezone.utc).isoformat(),
"ttl": _ttl_90_days(),
})
}
)
def save_coordination(channel, coord_thread_ts, conversation_thread_ts, started_by):
_get_table().put_item(Item={
_get_table().put_item(
Item={
"pk": f"COORD#{channel}#{coord_thread_ts}",
"sk": "COORD",
"conversation_thread_ts": conversation_thread_ts,
@ -166,7 +165,8 @@ def save_coordination(channel, coord_thread_ts, conversation_thread_ts, started_
"status": "active",
"created_at": datetime.now(timezone.utc).isoformat(),
"ttl": int(time.time()) + (7 * 24 * 60 * 60),
})
}
)
def get_coordination(channel, coord_thread_ts):
@ -203,7 +203,8 @@ def get_unanswered_threads(threshold_hours):
items.extend(resp.get("Items", []))
return [
item for item in items
item
for item in items
if datetime.fromisoformat(item["unanswered_since"]).timestamp() < cutoff
]
@ -213,7 +214,8 @@ def get_unanswered_threads(threshold_hours):
def create_task(title, due_date=None):
task_id = str(ulid.new())
_get_table().put_item(Item={
_get_table().put_item(
Item={
"pk": f"TASK#{task_id}",
"sk": "TASK",
"title": title,
@ -222,7 +224,8 @@ def create_task(title, due_date=None):
"created_at": datetime.now(timezone.utc).isoformat(),
"completed_at": "",
"ttl": _ttl_90_days(),
})
}
)
return task_id

View file

@ -1,6 +1,5 @@
import logging
import re
from datetime import datetime, timezone
import google.auth.transport.requests
from google.oauth2.credentials import Credentials

View file

@ -14,9 +14,7 @@ _config = None
def get_gmail_oauth():
global _gmail_oauth
if _gmail_oauth is None:
resp = _secrets_client.get_secret_value(
SecretId=os.environ["SECRET_GMAIL"]
)
resp = _secrets_client.get_secret_value(SecretId=os.environ["SECRET_GMAIL"])
_gmail_oauth = json.loads(resp["SecretString"])
return _gmail_oauth
@ -37,9 +35,7 @@ def get_slack_credentials():
"""Returns { botToken, signingSecret, appToken }."""
global _slack_creds
if _slack_creds is None:
resp = _secrets_client.get_secret_value(
SecretId=os.environ["SECRET_SLACK"]
)
resp = _secrets_client.get_secret_value(SecretId=os.environ["SECRET_SLACK"])
_slack_creds = json.loads(resp["SecretString"])
return _slack_creds
@ -67,6 +63,8 @@ def get_config():
"vip_senders": json.loads(params.get("vip-senders", "[]")),
"vip_domains": json.loads(params.get("vip-domains", "[]")),
"work_order_addresses": json.loads(params.get("work-order-addresses", "[]")),
"unanswered_threshold_hours": int(params.get("unanswered-threshold-hours", "24")),
"unanswered_threshold_hours": int(
params.get("unanswered-threshold-hours", "24")
),
}
return _config

View file

@ -1,4 +1,3 @@
import json
import logging
from datetime import datetime, timezone
@ -46,23 +45,35 @@ def build_high_priority_alert(message):
return [
{
"type": "header",
"text": {"type": "plain_text", "text": ":rotating_light: High-Priority Email"},
"text": {
"type": "plain_text",
"text": ":rotating_light: High-Priority Email",
},
},
{
"type": "section",
"fields": [
{"type": "mrkdwn", "text": f"*From*\n{message['from_name'] or message['from_email']}"},
{
"type": "mrkdwn",
"text": f"*From*\n{message['from_name'] or message['from_email']}",
},
{"type": "mrkdwn", "text": f"*Received*\n{time_ago}"},
],
},
{
"type": "section",
"text": {"type": "mrkdwn", "text": f"*{message['subject']}*\n{message['snippet'][:300]}"},
"text": {
"type": "mrkdwn",
"text": f"*{message['subject']}*\n{message['snippet'][:300]}",
},
},
{
"type": "context",
"elements": [
{"type": "mrkdwn", "text": f":brain: _{message.get('classification_reason', '')}_"},
{
"type": "mrkdwn",
"text": f":brain: _{message.get('classification_reason', '')}_",
},
],
},
{
@ -90,7 +101,10 @@ def build_daily_digest(high_items, bypassed, unanswered, normal_count, low_count
{
"type": "context",
"elements": [
{"type": "mrkdwn", "text": f"{today_str} · {total} emails processed today"},
{
"type": "mrkdwn",
"text": f"{today_str} · {total} emails processed today",
},
],
},
{"type": "divider"},
@ -108,7 +122,9 @@ def build_daily_digest(high_items, bypassed, unanswered, normal_count, low_count
)
if len(high_items) > max_items:
lines.append(f" _...and {len(high_items) - max_items} more_")
blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": "\n".join(lines)}})
blocks.append(
{"type": "section", "text": {"type": "mrkdwn", "text": "\n".join(lines)}}
)
blocks.append({"type": "divider"})
if bypassed:
@ -117,10 +133,14 @@ def build_daily_digest(high_items, bypassed, unanswered, normal_count, low_count
"_Sent directly to you without CC'ing work-orders@_",
]
for item in bypassed[:max_items]:
lines.append(f" · *{item['subject']}* from {item.get('from_name') or item['from_email']}")
lines.append(
f" · *{item['subject']}* from {item.get('from_name') or item['from_email']}"
)
if len(bypassed) > max_items:
lines.append(f" _...and {len(bypassed) - max_items} more_")
blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": "\n".join(lines)}})
blocks.append(
{"type": "section", "text": {"type": "mrkdwn", "text": "\n".join(lines)}}
)
blocks.append({"type": "divider"})
if unanswered:
@ -137,10 +157,13 @@ def build_daily_digest(high_items, bypassed, unanswered, normal_count, low_count
)
if len(unanswered) > max_items:
lines.append(f" _...and {len(unanswered) - max_items} more_")
blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": "\n".join(lines)}})
blocks.append(
{"type": "section", "text": {"type": "mrkdwn", "text": "\n".join(lines)}}
)
blocks.append({"type": "divider"})
blocks.append({
blocks.append(
{
"type": "context",
"elements": [
{
@ -152,7 +175,8 @@ def build_daily_digest(high_items, bypassed, unanswered, normal_count, low_count
),
}
],
})
}
)
return blocks