-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathrun_sync_opt_phase1.py
More file actions
175 lines (132 loc) · 6.46 KB
/
Copy pathrun_sync_opt_phase1.py
File metadata and controls
175 lines (132 loc) · 6.46 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
"""Phase 1: Gmail message-level dedup + parse failure dedup.
Priorities 1 and 4 from SYNC_EXECUTION_AUDIT.md.
Expected impact:
- Gmail API calls: ~1,100/sync → ~20-50/sync
- Sync time: -40-60s
- runs table growth: ~1,000/day → <20/day
"""
from langgraph_agents.graph_runner import run_graph
from langgraph_agents.graphs.plan_build_review import plan_build_review_app
WORKSPACE = r"C:\Users\senki\repos\job-cannon"
TASK_SUMMARY = """\
Implement Gmail message-level deduplication and parse failure log dedup in job-cannon.
Eliminates re-fetching and re-parsing ~1,100 already-seen Gmail messages per sync.
Constraints: uv run pytest only, raw SQLite SQL, Python type hints on all new signatures.
"""
TASK = """\
Implement Gmail message-level deduplication and parse failure log dedup in the job-cannon
ingestion pipeline. These are two interrelated fixes that together eliminate the biggest
source of waste: re-fetching and re-parsing 1,100 already-seen Gmail messages every sync.
Context:
- job-cannon is a personal job search Flask app (Python 3.13, SQLite, APScheduler)
- The ingestion pipeline runs 3x/day via APScheduler
- Gmail is the dominant source: ~1,100 emails fetched per sync, but only 11-27 are new
- The same emails are re-fetched, re-decoded, re-parsed on every sync — 3x/day for 7 days
- The `email_parse_log` table already exists with UNIQUE on `message_id` (currently unused for per-message tracking)
- The `runs` table has 7,546 rows, growing ~1,000/day from parse failure spam
Key constraints:
- Use `uv run pytest` for all test commands (never bare pytest)
- config.yaml must ONLY be modified with surgical Edit tool, NEVER full Write
- SQLite raw SQL only (no ORM)
- Python type hints on all new function signatures
--- IMPLEMENTATION PLAN ---
## Priority 1: Gmail Message-Level Dedup
### Goal
Skip re-fetching and re-parsing Gmail messages already processed in a previous sync.
### Changes
#### 1. `job_finder/sources/gmail_source.py` — Add message-level dedup
Current `fetch_jobs()` signature (around line 135):
def fetch_jobs(self, lookback_days: int = 7) -> list[Job]
New behavior:
- Accept an optional `processed_message_ids: set[str] | None = None` parameter
- After `_search_messages()` collects all message IDs, filter out IDs already in the set
- Log the skip count: `f"Gmail: skipping {skipped} already-processed messages"`
- After parsing each message (success or failure), accumulate message_id in a new return value
- Change return type to `tuple[list[Job], list[str]]` where the second element is the list
of message_ids that were processed (both successful and failed parses)
#### 2. `job_finder/web/pipeline_runner.py` — `_fetch_gmail()` wire-up
Before calling `source.fetch_jobs()`, query:
SELECT message_id FROM email_parse_log
WHERE processed_at >= datetime('now', '-{lookback_days} days')
Pass resulting set as `processed_message_ids` to `GmailSource.fetch_jobs()`.
After `fetch_jobs()` returns, bulk-insert the newly processed message_ids into `email_parse_log`:
INSERT OR IGNORE INTO email_parse_log (message_id, sender, subject, processed_at, job_count)
VALUES (?, 'gmail', '', datetime('now'), 0)
This prevents re-processing on the next sync.
Log: `f"Gmail dedup: {len(known_ids)} known, {len(new_ids)} new, {skipped} skipped"`
## Priority 4: Parse Failure Log Dedup
### Goal
Stop creating duplicate `runs` entries for the same failing emails every sync.
### Changes
#### 1. `job_finder/web/pipeline_runner.py` — Stop logging repeated failures
The parse failure path (around line 242-258) currently creates a `runs` row for every email
that parses to zero jobs. Since Priority 1 means those emails are now skipped on subsequent
syncs, this problem is largely eliminated for free.
However, add an explicit guard for the first-time failure case:
- Only insert a parse_failure `runs` row if this message_id is NOT already in `email_parse_log`
- This prevents duplicate failure rows if the same email fails on two consecutive runs before
the dedup kicks in
#### 2. `job_finder/web/scheduler.py` (or `pipeline_runner.py`) — Add runs table TTL pruning
Add to the orphan_cleanup or _run_enrichment_backfill job:
conn.execute(\"\"\"
DELETE FROM runs
WHERE timestamp < datetime('now', '-30 days')
AND source LIKE '%parse_failure%'
\"\"\")
conn.execute(\"\"\"
DELETE FROM runs
WHERE timestamp < datetime('now', '-90 days')
\"\"\")
### Schema verification
The `email_parse_log` table already exists. Verify it has these columns (check db_migrate.py):
message_id TEXT UNIQUE
sender TEXT
subject TEXT
processed_at TEXT
job_count INTEGER
If `processed_at` or `job_count` columns are missing, add a migration in `db_migrate.py`.
### Tests to add in `tests/test_gmail_source.py` (or existing test file)
1. `test_fetch_jobs_skips_known_message_ids` — mock _search_messages returning 3 IDs, pass 2 as
processed_message_ids, assert only 1 message is fetched via _get_message
2. `test_fetch_jobs_returns_processed_ids` — assert returned tuple[1] contains the message_ids
that were actually processed
3. `test_fetch_jobs_no_dedup_arg` — backward compat: no processed_message_ids means all fetched
### Verification command
uv run pytest tests/test_gmail_source.py -v --tb=short
"""
def main() -> None:
print(f"Task prompt: {len(TASK)} chars")
print(f"Workspace: {WORKSPACE}")
print("Starting Phase 1: Gmail dedup + parse failure dedup...\n")
result = run_graph(
plan_build_review_app,
{
"task": TASK_SUMMARY,
"current_plan": TASK,
"current_code": "",
"workspace_path": WORKSPACE,
"e2e_verdict": "",
"e2e_report": "",
"e2e_cycle": 0,
},
graph_name="sync_opt_phase1",
)
print("\n=== PHASE 1 COMPLETE ===")
print(f"E2E verdict: {result.get('e2e_verdict', 'N/A')}")
print(f"E2E cycles: {result.get('e2e_cycle', 0)}")
if result.get("e2e_report"):
report = result["e2e_report"]
print(f"\n=== E2E REPORT ({len(report)} chars) ===")
if len(report) > 3000:
print(f"...(showing last 3000 of {len(report)} chars)...")
report = report[-3000:]
print(report)
if result.get("current_code"):
diff = result["current_code"]
print(f"\n=== FINAL DIFF ({len(diff)} chars) ===")
if len(diff) > 2000:
print(f"...(truncated, showing last 2000 of {len(diff)} chars)...")
diff = diff[-2000:]
print(diff)
if __name__ == "__main__":
main()