Snapshot: full project state
This commit is contained in:
442
tailor.py
Normal file
442
tailor.py
Normal file
@@ -0,0 +1,442 @@
|
||||
# Procyon — resume tailoring + fit scoring + cover email drafting.
|
||||
# LLM-first, deterministic keyword fallback. Honest-only: never fabricates.
|
||||
# Holds a resume LIBRARY (tech / hydro / driving) and combines the right
|
||||
# resumes per job ("the grower who also builds" differentiator).
|
||||
|
||||
import os
|
||||
|
||||
import db
|
||||
import llm
|
||||
|
||||
RESUME_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'resumes')
|
||||
|
||||
# library: track -> ordered list of resume files (first = primary)
|
||||
TRACKS = {
|
||||
'tech': ['tech-costco.txt', 'tech-polished.txt'],
|
||||
'hydro': ['hydro-clean.txt'],
|
||||
'driving': ['driving.txt'],
|
||||
}
|
||||
|
||||
HYDRO_KW = ['hydroponic', 'horticulture', 'grower', 'cultivat', 'greenhouse', 'agricultur',
|
||||
'nursery', 'botan', 'plant', 'ipm', 'irrigation', 'nutrient', 'cannabis', 'crop']
|
||||
DRIVE_KW = ['delivery', 'driver', 'logistics', 'warehouse', 'forklift', 'route', 'fleet',
|
||||
'dispatch', 'courier', 'cdl', 'field operations', 'shipping', 'transport']
|
||||
TECH_KW = ['software', 'engineer', 'developer', 'devops', 'sysadmin', 'system admin',
|
||||
'infrastructure', 'cloud', 'python', 'linux', 'it ', 'network', 'ai',
|
||||
'automation', 'full-stack', 'full stack', 'backend', 'frontend', 'sre',
|
||||
'platform', 'data ', 'security', 'mcp', 'llm']
|
||||
|
||||
|
||||
def _load(name):
|
||||
p = os.path.join(RESUME_DIR, name)
|
||||
if os.path.exists(p):
|
||||
with open(p, 'r', encoding='utf-8', errors='replace') as f:
|
||||
return f.read()
|
||||
return ''
|
||||
|
||||
|
||||
def list_library():
|
||||
out = []
|
||||
for track, files in TRACKS.items():
|
||||
for fn in files:
|
||||
p = os.path.join(RESUME_DIR, fn)
|
||||
out.append({'track': track, 'file': fn, 'exists': os.path.exists(p),
|
||||
'chars': os.path.getsize(p) if os.path.exists(p) else 0})
|
||||
return out
|
||||
|
||||
|
||||
def classify_track(job):
|
||||
"""Return the primary track for a job based on keyword density."""
|
||||
text = ((job.get('title') or '') + ' ' + (job.get('description') or '')).lower()
|
||||
hydro = sum(1 for k in HYDRO_KW if k in text)
|
||||
drive = sum(1 for k in DRIVE_KW if k in text)
|
||||
tech = sum(1 for k in TECH_KW if k in text)
|
||||
# default tech when ambiguous (his primary target)
|
||||
if hydro > drive and hydro >= tech and hydro > 0:
|
||||
return 'hydro'
|
||||
if drive > hydro and drive >= tech and drive > 0:
|
||||
return 'driving'
|
||||
return 'tech'
|
||||
|
||||
|
||||
def _tech_differentiator():
|
||||
"""Compact 'also a builder' block extracted from the tech resume."""
|
||||
tech = _load('tech-costco.txt')
|
||||
profile = ''
|
||||
if '## Technical Profile' in tech:
|
||||
profile = tech.split('## Technical Profile', 1)[1].split('## Core Engineering', 1)[0]
|
||||
return ('\n\n## Technical Differentiator\n\n'
|
||||
f"Beyond cultivation, I'm a self-taught full-stack developer and infrastructure "
|
||||
f"engineer who builds the software and sensor hardware that modern growing "
|
||||
f"operations run on (see hydro.thetempleofdoom.com).\n{profile.strip()}")
|
||||
|
||||
|
||||
def build_master(job):
|
||||
"""Select + combine the right resumes. Returns (tracks_used, combined_text)."""
|
||||
track = classify_track(job)
|
||||
tracks = [track]
|
||||
if track == 'hydro':
|
||||
text = _load('hydro-clean.txt') + _tech_differentiator()
|
||||
tracks.append('tech')
|
||||
elif track == 'driving':
|
||||
text = _load('driving.txt')
|
||||
# if the role has a meaningful tech/ops slant, fold in the tech profile
|
||||
jt = ((job.get('title') or '') + ' ' + (job.get('description') or '')).lower()
|
||||
if sum(1 for k in TECH_KW if k in jt) >= 2:
|
||||
text += _tech_differentiator()
|
||||
tracks.append('tech')
|
||||
else:
|
||||
text = _load('tech-costco.txt')
|
||||
if not text.strip():
|
||||
text = _load('tech-polished.txt')
|
||||
# single-resume override (Settings) takes precedence
|
||||
override = db.get_setting('master_resume', '')
|
||||
if override and os.path.exists(override):
|
||||
with open(override, 'r', encoding='utf-8', errors='replace') as f:
|
||||
return ['custom'], f.read()
|
||||
# always append the canonical work history so tailoring has the full verified record
|
||||
wh = _load('work-history.txt')
|
||||
if wh:
|
||||
text += '\n\n## WORK HISTORY (verified facts)\n' + wh
|
||||
return tracks, text
|
||||
|
||||
|
||||
def score_job(job):
|
||||
"""Return (score 0..1, opinion string)."""
|
||||
desc = job.get('description') or ''
|
||||
title = job.get('title') or ''
|
||||
company = job.get('company') or ''
|
||||
track = classify_track(job)
|
||||
prompt = (
|
||||
f"Score how well this job fits a self-taught full-stack developer and self-hosted "
|
||||
f"infrastructure engineer (Python, Flask, Linux, Proxmox, Docker, networking, "
|
||||
f"automation, LLM/agent tooling). Candidate track: {track}. Return JSON: "
|
||||
f"{{\"score\": <0.0 to 1.0>, \"opinion\": \"<one sentence why>\", "
|
||||
f"\"top_skills\": [\"..\"]}}\n\n"
|
||||
f"Job title: {title}\nCompany: {company}\n\n{desc[:3000]}"
|
||||
)
|
||||
data = llm.llm_json(prompt)
|
||||
if data and isinstance(data, dict) and 'score' in data:
|
||||
try:
|
||||
score = float(data['score'])
|
||||
except (TypeError, ValueError):
|
||||
score = 0.5
|
||||
return round(max(0.0, min(1.0, score)), 3), str(data.get('opinion', ''))
|
||||
score, opinion = llm.keyword_score(desc)
|
||||
return score, opinion
|
||||
|
||||
|
||||
def tailor_resume(job):
|
||||
"""Tailor the combined master resume for a specific job. Returns tailored text."""
|
||||
tracks, master = build_master(job)
|
||||
title = job.get('title') or ''
|
||||
company = job.get('company') or ''
|
||||
desc = (job.get('description') or '')[:3000]
|
||||
prompt = (
|
||||
f"Rewrite this resume to target the role '{title}' at {company}. "
|
||||
"Keep every fact identical — same employers, titles, dates, skills, credentials. "
|
||||
"Only reorder sections to surface the most relevant experience first and rephrase bullet "
|
||||
"points to echo the job description's own terminology WITHOUT inventing anything. "
|
||||
"Do not add skills the candidate doesn't have. Do NOT add a 'Targeting' header or any "
|
||||
"meta-commentary — it should read like a normal resume.\n\n"
|
||||
f"JOB DESCRIPTION:\n{desc}\n\nMASTER RESUME (combined tracks: {', '.join(tracks)}):\n{master}"
|
||||
)
|
||||
tailored = llm.llm_chat(prompt)
|
||||
if tailored:
|
||||
return tailored
|
||||
# keyword fallback: keep master intact (no fabricated targeting header)
|
||||
return master
|
||||
|
||||
|
||||
def draft_email(job, tailored_resume, research=''):
|
||||
"""Draft a tailored outreach email. No fabrication; human, specific, natural voice."""
|
||||
title = job.get('title') or 'the role'
|
||||
company = job.get('company') or 'your team'
|
||||
from_name = db.get_setting('from_name', 'Indiana Holmes')
|
||||
phone = db.get_setting('resume_phone', '')
|
||||
email = db.get_setting('resume_email', '')
|
||||
website = db.get_setting('website', 'https://thetempleofdoom.com').strip()
|
||||
|
||||
research_hint = ''
|
||||
if research:
|
||||
research_hint = (
|
||||
f"\n\nCOMPANY CONTEXT (weave ONE natural, specific reference into the body — show "
|
||||
f"you actually looked at what they do; do NOT lead with it or recite it like a "
|
||||
f"fact sheet):\n{research[:1200]}\n"
|
||||
)
|
||||
|
||||
prompt = (
|
||||
f"You are {from_name}, a self-taught full-stack developer and infrastructure engineer "
|
||||
f"based in Seattle, WA. You are applying to {company} for the '{title}' role as an "
|
||||
f"EXTERNAL candidate — you have NEVER worked at {company}, and you do NOT currently "
|
||||
f"hold this role or any role there. Your only real experience is what is in the "
|
||||
f"TAILORED RESUME below.\n\n"
|
||||
f"Write a short, warm, plain-spoken cold-application email for that role. It should read "
|
||||
f"like a real person wrote it quickly and confidently — not a template and not "
|
||||
f"AI-sounding. Rules:\n"
|
||||
"- 2-3 short paragraphs, varied sentence length, concrete and specific.\n"
|
||||
"- NO buzzwords and NO cliche phrases: 'I am writing to', 'I hope this email finds you "
|
||||
"well', 'I would welcome', 'I am excited to', 'passionate about', 'I believe', "
|
||||
"'leverage', 'delve', 'synergy'. Avoid em-dashes and semicolons.\n"
|
||||
"- Open naturally with a specific reason this role caught your eye, then name the TWO "
|
||||
"strongest RELEVANT qualifications from the resume in plain concrete terms, then a "
|
||||
"short direct close.\n"
|
||||
"- If a COMPANY CONTEXT block is present, reference ONE specific fact about the company "
|
||||
"as something you've noticed about THEM (e.g. 'I've been following FreedomPay's work in "
|
||||
"payments'), NOT as your own employment. NEVER claim or imply you currently work at "
|
||||
f"{company}, have ever worked at {company}, or currently hold the '{title}' role.\n"
|
||||
"- Do NOT invent any employer, job title, date, skill, or metric that is not in the "
|
||||
"resume. If the resume does not support a claim, do not make it.\n"
|
||||
"- End with a short direct closing line. Do NOT include your name, phone number, email, "
|
||||
"or website — the signature is appended automatically.\n"
|
||||
"- Return ONLY the email body text (no subject line, no preamble).\n\n"
|
||||
f"TAILORED RESUME:\n{tailored_resume[:4000]}"
|
||||
f"{research_hint}"
|
||||
)
|
||||
body = llm.llm_chat(prompt)
|
||||
body = _clean(body)
|
||||
if body and not _looks_fabricated(body, company, title):
|
||||
return _append_signature(body, from_name, phone, email, website)
|
||||
return (
|
||||
f"Hello,\n\n"
|
||||
f"I came across the {title} opening at {company} and it lines up well with what I do. "
|
||||
f"I'm a self-taught full-stack developer and infrastructure engineer — I build and run "
|
||||
f"Python/Flask services, Linux servers, Proxmox virtualization, Docker, and agent/LLM "
|
||||
f"tooling on hardware I operate myself.\n\n"
|
||||
f"If you're open to it, I'd like to talk about how that hands-on background fits the "
|
||||
f"team.\n\n"
|
||||
f"Thanks,\n{from_name}\n{phone}\n{email}\n{website}"
|
||||
)
|
||||
|
||||
|
||||
def _append_signature(body, from_name, phone, email, website):
|
||||
"""Deterministically append the signature block. The model reliably drops it, so
|
||||
never trust it to sign its own output. Skips if a signature is already present."""
|
||||
if (website and website in body) or (email and email in body):
|
||||
return body
|
||||
sig = f"{from_name}\n{phone}\n{email}\n{website}"
|
||||
return (body.rstrip() + f"\n\n{sig}").strip()
|
||||
|
||||
|
||||
def _clean(body):
|
||||
"""Strip broken-tokenizer artifacts and a leading 'Subject:' line from LLM output.
|
||||
The local models intermittently leak `<unusedNN>` / `????` tokens; if nothing
|
||||
usable remains, return '' so the caller falls back to the safe template."""
|
||||
import re
|
||||
if not body:
|
||||
return ''
|
||||
body = re.sub(r'<unused\d+>', '', body)
|
||||
body = re.sub(r'^[\s?]+', '', body)
|
||||
lines = body.splitlines()
|
||||
while lines and lines[0].strip().lower().startswith('subject:'):
|
||||
lines = lines[1:]
|
||||
cleaned = '\n'.join(lines).strip()
|
||||
# if after cleanup it's essentially empty or pure punctuation, treat as garbage
|
||||
if not cleaned or not any(ch.isalnum() for ch in cleaned):
|
||||
return ''
|
||||
return cleaned
|
||||
|
||||
|
||||
def _looks_fabricated(body, company, title):
|
||||
"""Deterministic guard: reject LLM output that claims the candidate currently
|
||||
works at the target company or already holds the applied-for role (weak-model
|
||||
hallucination). Returns True if the body looks fabricated."""
|
||||
if not body:
|
||||
return True
|
||||
low = body.lower()
|
||||
c = (company or '').lower().strip()
|
||||
t = (title or '').lower().strip()
|
||||
markers = ['right now i ', 'i currently ', 'currently work', "i'm currently",
|
||||
'i am currently', 'i joined ', 'my role at', 'i work at', 'i own the ',
|
||||
"i've been at ", 'i lead the ', 'i manage the ']
|
||||
hits = [m for m in markers if m in low]
|
||||
# present-tense claim tying the candidate to the company/title
|
||||
if c and c in low:
|
||||
for phrase in (f'at {c} where i', f'at {c}, where i', f'working at {c}',
|
||||
f'i am a {t} at {c}', f"i'm a {t} at {c}"):
|
||||
if phrase in low:
|
||||
hits.append(phrase)
|
||||
return bool(hits)
|
||||
|
||||
|
||||
def make_subject(job):
|
||||
title = job.get('title') or 'Open Role'
|
||||
company = job.get('company') or ''
|
||||
return f"Application: {title}" + (f" — {company}" if company else "")
|
||||
|
||||
|
||||
# ===================== structured one-page resume =====================
|
||||
# Verified facts only (from work-history.txt). Renders to a one-page PDF.
|
||||
|
||||
EXPERIENCE = [
|
||||
{'title': 'Water System Technician', 'company': 'Northwest Water Systems Inc',
|
||||
'dates': '2025', 'tracks': ['tech', 'driving'],
|
||||
'bullets': [
|
||||
'Maintained 45 pump houses across WA — install, repair, and troubleshoot water treatment & distribution infrastructure.',
|
||||
'Field inspections, EPA-standard water sampling, emergency response, and compliance documentation.',
|
||||
'Operated pumps, filtration, and chemical-dosing equipment.']},
|
||||
{'title': 'Lead Field Operations Specialist', 'company': 'Lime',
|
||||
'dates': '2023 – 2025', 'tracks': ['tech', 'driving'],
|
||||
'bullets': [
|
||||
'Led daily field operations to keep the scooter/bike fleet available; supervised 5 field agents.',
|
||||
'Coordinated dispatch logistics, GPS-based asset recovery, and route optimization.',
|
||||
'Repaired and replaced vehicle parts; directed drivers via two-way radio.']},
|
||||
{'title': 'Master Grower / Cultivation Manager', 'company': 'Los Angeles, CA (controlled-environment agriculture)',
|
||||
'dates': '2014 – 2023', 'tracks': ['hydro'],
|
||||
'bullets': [
|
||||
'Managed full cultivation cycles (propagation → harvest) and designed custom hydroponic systems.',
|
||||
'Developed nutrient programs; maintained lighting, humidity, airflow, and temperature control.',
|
||||
'Implemented integrated pest management; trained and supervised growers.']},
|
||||
{'title': 'Lead Service Representative', 'company': 'LabCorp',
|
||||
'dates': '2022 – 2023', 'tracks': ['driving', 'tech'],
|
||||
'bullets': [
|
||||
'Ran daily medical specimen pickup routes across hospitals and clinics with strict regulatory compliance.',
|
||||
'Coordinated emergency pickups and route adjustments with dispatch.']},
|
||||
{'title': 'Field Operations / Warehouse Specialist', 'company': 'Aboda',
|
||||
'dates': '2019 – 2022', 'tracks': ['driving', 'tech'],
|
||||
'bullets': [
|
||||
'Managed logistics and setup for corporate housing units across Seattle and Bellevue.',
|
||||
'Operated forklifts; maintained warehouse inventory and move-in-ready quality inspections.']},
|
||||
{'title': 'Sales Manager', 'company': 'Mission Motors',
|
||||
'dates': '2017 – 2019', 'tracks': ['driving'],
|
||||
'bullets': ['Managed vehicle inventory and sales; guided customers through the full purchase process.']},
|
||||
{'title': 'Logistics Coordinator', 'company': 'Salt Works',
|
||||
'dates': '2012', 'tracks': ['driving'],
|
||||
'bullets': ['Loaded pallets into shipping containers and box trucks; operated forklift and cherry picker.']},
|
||||
]
|
||||
|
||||
PROJECTS = [
|
||||
{'title': 'Self-Hosted Infrastructure (Proxmox homelab)',
|
||||
'bullets': [
|
||||
'Operate a multi-node Proxmox cluster running AI services, databases, websites, and automation.',
|
||||
'Built a self-hosted SaaS ecosystem (Git, automation, cloud storage) with secure remote access.']},
|
||||
{'title': 'AI Automation & Local LLM Pipelines',
|
||||
'bullets': [
|
||||
'Built local-LLM agent workflows and MCP integrations for research, monitoring, and data processing.']},
|
||||
{'title': 'Embedded & RF Systems (ESP32, LoRa / Meshtastic)',
|
||||
'bullets': [
|
||||
'Engineered ESP32 telemetry and sensor devices with custom firmware; built LoRa/Meshtastic mesh networks.']},
|
||||
]
|
||||
|
||||
SUMMARIES = {
|
||||
'tech': 'Self-taught full-stack and embedded systems engineer who builds across the entire stack — '
|
||||
'Python/Flask backends, Linux/Docker/Proxmox infrastructure, ESP32 firmware for embedded devices, '
|
||||
'LoRa/Meshtastic mesh networks, and local AI/LLM agent tooling — on hardware I design and operate myself.',
|
||||
'hydro': 'Self-taught horticulture specialist with 14+ years in hydroponics and controlled-environment '
|
||||
'agriculture — custom grow systems, plant nutrition, environmental control, and high-yield production.',
|
||||
'driving': 'Reliable logistics and field-operations professional with extensive route-driving, fleet, and '
|
||||
'warehouse experience — safety-focused with strong dispatch coordination and problem-solving.',
|
||||
}
|
||||
|
||||
SKILLS = {
|
||||
'tech': ['Embedded firmware (ESP32)', 'LoRa / Meshtastic mesh networks', 'Embedded systems design',
|
||||
'Python', 'Bash', 'Proxmox / virtualization', 'Linux administration', 'Docker',
|
||||
'Networking / SSH / VPN', 'REST APIs', 'Git', 'AI / LLM infrastructure',
|
||||
'Agent & MCP development', 'Monitoring / Grafana', 'Automation pipelines'],
|
||||
'hydro': ['Hydroponic system design', 'Plant propagation', 'Nutrient programs', 'IPM',
|
||||
'Environmental control', 'Grow-facility design', 'Grower training',
|
||||
'Plant-health troubleshooting'],
|
||||
'driving': ['Route driving & delivery', 'Forklift (certified)', 'Fleet tracking & recovery',
|
||||
'Warehouse inventory', 'Field operations', 'GPS route optimization',
|
||||
'Dispatch coordination', 'Customer service'],
|
||||
}
|
||||
|
||||
EDUCATION = 'Lake Stevens High School — Diploma (Computer Applications & Technology)'
|
||||
|
||||
TRACK_HEADLINES = {
|
||||
'tech': 'Full-Stack & Embedded Systems Engineer',
|
||||
'hydro': 'Hydroponics & Controlled-Environment Agriculture Specialist',
|
||||
'driving': 'Logistics & Field Operations Professional',
|
||||
}
|
||||
|
||||
|
||||
def _deterministic_structured(track, job):
|
||||
"""Deterministic one-page structured resume from verified facts (no LLM)."""
|
||||
headline = TRACK_HEADLINES.get(track, TRACK_HEADLINES['tech'])
|
||||
exp = [e for e in EXPERIENCE if track in e['tracks']]
|
||||
resume = {
|
||||
'contact': 'Seattle, WA | 425-280-0023 | indianaholmes1@icloud.com',
|
||||
'headline': headline,
|
||||
'summary': SUMMARIES.get(track, SUMMARIES['tech']),
|
||||
'skills': SKILLS.get(track, SKILLS['tech']),
|
||||
'experience': exp,
|
||||
'education': EDUCATION,
|
||||
}
|
||||
if track == 'tech':
|
||||
resume['projects'] = PROJECTS
|
||||
return resume
|
||||
|
||||
|
||||
def structured_resume(job):
|
||||
"""Deterministic one-page resume from verified facts. (Was LLM-first, but the JSON
|
||||
generation call was slow under MacBook memory pressure and its failure tripped the
|
||||
circuit breaker — blocking the email draft, which is the call that actually matters.
|
||||
The deterministic path is honest and clean, so use it exclusively.)"""
|
||||
track = classify_track(job)
|
||||
return _deterministic_structured(track, job)
|
||||
|
||||
|
||||
def structured_to_text(resume):
|
||||
"""Render a structured resume dict to readable plain text (for display + audit)."""
|
||||
lines = []
|
||||
if resume.get('headline'):
|
||||
lines.append(resume['headline'])
|
||||
if resume.get('summary'):
|
||||
lines.append(resume['summary'])
|
||||
if resume.get('skills'):
|
||||
lines.append('\nSKILLS: ' + ' • '.join(resume['skills']))
|
||||
if resume.get('experience'):
|
||||
lines.append('\nEXPERIENCE:')
|
||||
for e in resume['experience']:
|
||||
lines.append(f" {e.get('title')} — {e.get('company')} ({e.get('dates')})")
|
||||
for b in e.get('bullets', []):
|
||||
lines.append(f" - {b}")
|
||||
if resume.get('projects'):
|
||||
lines.append('\nPROJECTS:')
|
||||
for p in resume['projects']:
|
||||
lines.append(f" {p.get('title')}")
|
||||
for b in p.get('bullets', []):
|
||||
lines.append(f" - {b}")
|
||||
if resume.get('education'):
|
||||
lines.append('\nEDUCATION: ' + resume['education'])
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
# ---- ATS optimization & gap analysis (quality steps) ----
|
||||
|
||||
def extract_job_keywords(description, top_n=15):
|
||||
"""Extract the most signal-bearing keywords from a job description."""
|
||||
import re as _re
|
||||
if not description:
|
||||
return []
|
||||
d = description.lower()
|
||||
freq = {}
|
||||
for kw in llm._skill_keywords():
|
||||
if kw in d:
|
||||
freq[kw] = d.count(kw)
|
||||
# capitalized technical terms (e.g. "Kubernetes", "AWS", "Terraform")
|
||||
for m in _re.findall(r'\b[A-Z][A-Za-z0-9+#./]{2,}\b', description):
|
||||
k = m.lower()
|
||||
if k not in freq and len(k) >= 3:
|
||||
freq[k] = description.lower().count(k)
|
||||
ranked = sorted(freq.items(), key=lambda x: -x[1])
|
||||
return [k for k, _ in ranked[:top_n]]
|
||||
|
||||
|
||||
def ats_coverage(text, keywords):
|
||||
"""Return (coverage 0..1, keywords, missing_keywords) for ATS matching."""
|
||||
if not keywords:
|
||||
return 1.0, [], []
|
||||
t = (text or '').lower()
|
||||
missing = [k for k in keywords if k.lower() not in t]
|
||||
hit = len(keywords) - len(missing)
|
||||
return round(hit / len(keywords), 3), keywords, missing
|
||||
|
||||
|
||||
def candidate_gaps(job):
|
||||
"""Job keywords absent from the candidate's skill surface -> honest fit gaps."""
|
||||
desc = (job.get('description') or '') + ' ' + (job.get('title') or '')
|
||||
kws = extract_job_keywords(desc, top_n=12)
|
||||
_tracks, master = build_master(job)
|
||||
cov, _kws, missing = ats_coverage(master, kws)
|
||||
return {'keywords': kws, 'missing': missing, 'coverage': cov}
|
||||
Reference in New Issue
Block a user