Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions applyr/commands/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,10 @@ def cmd_pipeline(min_score: int = 0, as_json: bool = False) -> None:
conn.close()

if not rows:
print("No offers in the database.")
if as_json:
print(json.dumps({status: [] for status in _STATUS_ORDER}, indent=2, ensure_ascii=False))
else:
print("No offers in the database.")
return

# Group by status
Expand Down Expand Up @@ -294,7 +297,10 @@ def cmd_gaps(limit: int = 10, as_json: bool = False) -> None:
rows = _live_skill_gaps(limit)

if not rows:
print("No skill gaps recorded yet.")
if as_json:
print(json.dumps([], indent=2, ensure_ascii=False))
else:
print("No skill gaps recorded yet.")
return

worst_gap = max(r["total_gap"] for r in rows)
Expand Down Expand Up @@ -438,7 +444,10 @@ def cmd_trends(period: str = "week", as_json: bool = False) -> None:
conn.close()

if not rows:
print("No dated offers found.")
if as_json:
print(json.dumps([], indent=2, ensure_ascii=False))
else:
print("No dated offers found.")
return

if as_json:
Expand Down Expand Up @@ -660,7 +669,10 @@ def cmd_plan(limit: int = 10, as_json: bool = False) -> None:
rows = _live_skill_gaps()

if not rows:
print("No skill gaps recorded yet.")
if as_json:
print(json.dumps([], indent=2, ensure_ascii=False))
else:
print("No skill gaps recorded yet.")
return

# `total_gap` is already frequency times average gap — the points a topic
Expand Down Expand Up @@ -724,7 +736,10 @@ def cmd_salary(seniority: str | None = None, category: str | None = None, as_jso
conn.close()

if not rows:
print("No salary data available.")
if as_json:
print(json.dumps({"by_seniority": [], "by_category": []}, indent=2, ensure_ascii=False))
else:
print("No salary data available.")
return

# Group by seniority
Expand Down
16 changes: 11 additions & 5 deletions applyr/commands/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -785,10 +785,13 @@ def cmd_list(status_filter: str | None = None, sort_by: str = "date_applied", li
conn.close()

if not rows:
msg = f"No offers found"
if status_filter:
msg += f" with status '{status_filter}'"
print(msg + ".")
if as_json:
print(json.dumps([], indent=2, ensure_ascii=False))
else:
msg = f"No offers found"
if status_filter:
msg += f" with status '{status_filter}'"
print(msg + ".")
return

if as_json:
Expand Down Expand Up @@ -1125,7 +1128,10 @@ def cmd_search(keyword: str, status_filter: str | None = None, company: str | No
target = f"company '{company}'" if company else f"'{keyword}'"

if not rows:
print(f"No offers found matching {target}.")
if as_json:
print(json.dumps([], indent=2, ensure_ascii=False))
else:
print(f"No offers found matching {target}.")
return

if as_json:
Expand Down
32 changes: 29 additions & 3 deletions applyr/duplicates.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

import re
import sqlite3
import unicodedata
from difflib import SequenceMatcher

from applyr.constants import DUPLICATE_SIMILARITY_THRESHOLD
Expand Down Expand Up @@ -58,13 +59,37 @@ def title_similarity(a: str, b: str) -> float:
return SequenceMatcher(None, norm_a, norm_b).ratio()


def normalize_company(text: str | None) -> str:
"""Lowercase and strip diacritics, so "Mática" and "Matica" compare equal.

Company names get typed inconsistently far more often than two
genuinely different companies happen to share a name — a posting
pasted from LinkedIn, OCR, or a rushed manual entry drops accents far
more often than it invents a real collision. Unlike substring or fuzzy
matching (rejected for company names — see module docstring), stripping
diacritics never merges two different companies together, so it carries
none of that false-positive risk.
"""
if not text:
return ""
decomposed = unicodedata.normalize("NFKD", text)
return "".join(c for c in decomposed if not unicodedata.combining(c)).lower()


def _register_company_normalizer(conn: sqlite3.Connection) -> None:
"""Expose normalize_company() to SQL as unaccent_lower(). Idempotent —
re-registering the same function on a connection is safe and cheap."""
conn.create_function("unaccent_lower", 1, normalize_company)


def find_exact(conn: sqlite3.Connection, title: str, company: str | None) -> sqlite3.Row | None:
"""Find an offer with the same title and company, case-insensitively."""
"""Find an offer with the same title and company, case/accent-insensitively."""
_register_company_normalizer(conn)
return conn.execute(
"""SELECT id, title, status, date_received, compatibility_pct
FROM offers
WHERE LOWER(title) = LOWER(?)
AND LOWER(COALESCE(company,'')) = LOWER(COALESCE(?,''))""",
AND unaccent_lower(COALESCE(company,'')) = unaccent_lower(COALESCE(?,''))""",
(title, company),
).fetchone()

Expand All @@ -73,10 +98,11 @@ def find_company_offers(conn: sqlite3.Connection, company: str | None) -> list[s
"""All offers already recorded for a company, newest first."""
if not company:
return []
_register_company_normalizer(conn)
return conn.execute(
"""SELECT id, title, status, date_received, compatibility_pct
FROM offers
WHERE LOWER(COALESCE(company,'')) = LOWER(?)
WHERE unaccent_lower(COALESCE(company,'')) = unaccent_lower(?)
ORDER BY id DESC""",
(company,),
).fetchall()
Expand Down
19 changes: 6 additions & 13 deletions tests/test_cli_routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -438,13 +438,9 @@ def test_json_no_color_combined(self, run_cli, capsys, tmp_db):
def test_json_flag_gaps(self, run_cli, capsys, tmp_db):
out, err, code = _run(run_cli, capsys, ["--json", "gaps"])
assert code == 0
# With empty DB, gaps may print a message or empty JSON
if out.strip():
try:
data = json.loads(out)
assert isinstance(data, dict)
except json.JSONDecodeError:
pass # Some commands print messages instead of JSON when empty
# cmd_gaps's JSON payload is a list of skill-gap entries — empty DB is []
data = json.loads(out)
assert isinstance(data, list)

def test_json_flag_followups(self, run_cli, capsys, tmp_db):
out, err, code = _run(run_cli, capsys, ["--json", "followups"])
Expand Down Expand Up @@ -479,12 +475,9 @@ def test_json_flag_summary(self, run_cli, capsys, tmp_db):
def test_json_flag_trends(self, run_cli, capsys, tmp_db):
out, err, code = _run(run_cli, capsys, ["--json", "trends"])
assert code == 0
if out.strip():
try:
data = json.loads(out)
assert isinstance(data, dict)
except json.JSONDecodeError:
pass
# cmd_trends's JSON payload is a list of period entries — empty DB is []
data = json.loads(out)
assert isinstance(data, list)

def test_json_flag_doctor(self, run_cli, capsys, tmp_db):
out, err, code = _run(run_cli, capsys, ["--json", "doctor"])
Expand Down
56 changes: 55 additions & 1 deletion tests/test_duplicates.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@
import pytest

from applyr.constants import DUPLICATE_SIMILARITY_THRESHOLD
from applyr.duplicates import find_similar, normalize_title, title_similarity
from applyr.duplicates import (
find_company_offers,
find_exact,
find_similar,
normalize_company,
normalize_title,
title_similarity,
)


class TestNormalizeTitle:
Expand Down Expand Up @@ -98,3 +105,50 @@ def test_respects_custom_threshold(self):
def test_common_posting_variants_are_caught(self, variant):
rows = [FakeRow(id=1, title=variant)]
assert find_similar(rows, "Backend Engineer") is not None


class TestNormalizeCompany:
def test_lowercases(self):
assert normalize_company("Acme Corp") == "acme corp"

def test_strips_diacritics(self):
assert normalize_company("Mática Partners") == "matica partners"

def test_already_unaccented_is_unchanged_besides_case(self):
assert normalize_company("Matica Partners") == "matica partners"

def test_none_becomes_empty_string(self):
assert normalize_company(None) == ""


class TestFindExactAndFindCompanyOffers:
"""Found via a real offer: "Mática Partners" and "Matica Partners" were
treated as two different companies because the SQL comparison only
lowercased, never stripped accents — so add's duplicate warning and
search --company silently missed real history at the same company.
"""

@pytest.fixture
def conn(self, tmp_db):
from applyr.db import get_conn

connection = get_conn(tmp_db)
connection.execute(
"INSERT INTO offers (title, company) VALUES (?, ?)",
("IA Engineer", "Matica Partners"),
)
connection.commit()
return connection

def test_find_company_offers_ignores_accent_differences(self, conn):
rows = find_company_offers(conn, "Mática Partners")
assert len(rows) == 1
assert rows[0]["title"] == "IA Engineer"

def test_find_exact_ignores_accent_differences(self, conn):
row = find_exact(conn, "IA Engineer", "Mática Partners")
assert row is not None

def test_find_exact_still_requires_the_same_title(self, conn):
row = find_exact(conn, "Backend Developer", "Mática Partners")
assert row is None
61 changes: 61 additions & 0 deletions tests/test_empty_results_json.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""--json must stay parseable even when there is nothing to report.

Found while auditing applyr end to end: every list-shaped command printed a
human sentence ("No offers found.", "No skill gaps recorded yet.", ...) on
an empty result set regardless of --json, breaking the one contract
AGENT_INSTRUCTIONS.md promises agent callers ("--json en todos los
comandos"). cmd_gaps_list already did this right (`{"total": 0, "gaps": []}`
even when empty); the rest are brought in line with it here.
"""

import json

import pytest

from applyr.commands import (
cmd_gaps,
cmd_list,
cmd_pipeline,
cmd_plan,
cmd_salary,
cmd_search,
cmd_trends,
)


@pytest.mark.unit
class TestEmptyResultsStayJson:

def test_list(self, tmp_db, tmp_applyr, capsys):
cmd_list(as_json=True)
assert json.loads(capsys.readouterr().out) == []

def test_search(self, tmp_db, tmp_applyr, capsys):
cmd_search("nothing-matches-this", as_json=True)
assert json.loads(capsys.readouterr().out) == []

def test_search_company(self, tmp_db, tmp_applyr, capsys):
cmd_search("", company="Nobody Inc", as_json=True)
assert json.loads(capsys.readouterr().out) == []

def test_pipeline(self, tmp_db, tmp_applyr, capsys):
cmd_pipeline(as_json=True)
payload = json.loads(capsys.readouterr().out)
assert isinstance(payload, dict)
assert all(v == [] for v in payload.values())

def test_gaps(self, tmp_db, tmp_applyr, capsys):
cmd_gaps(as_json=True)
assert json.loads(capsys.readouterr().out) == []

def test_trends(self, tmp_db, tmp_applyr, capsys):
cmd_trends(as_json=True)
assert json.loads(capsys.readouterr().out) == []

def test_plan(self, tmp_db, tmp_applyr, capsys):
cmd_plan(as_json=True)
assert json.loads(capsys.readouterr().out) == []

def test_salary(self, tmp_db, tmp_applyr, capsys):
cmd_salary(as_json=True)
assert json.loads(capsys.readouterr().out) == {"by_seniority": [], "by_category": []}
Loading