---
description: "Daily public-procurement matcher for IT consultants — fetches open tenders from upphandling.mag.to, scores them against a local profile YAML, deeply analyses stretch+ matches against the consultant's CV, and writes a daily Markdown report. First run walks a Q&A intake to build ~/upphandling-profile.yaml, ~/upphandling-cv.md, and ~/upphandling-preferences.md. Use when the consultant says 'check upphandlingar', 'run the matcher', 'kolla dagens upphandlingar', or invokes /upphandling-match directly."
user-invocable: true
allowed-tools: Read, Write, Edit, Bash
argument-hint: "[--reintake | --all | --include-closed]"
---

# upphandling-match

## What this skill does

Pulls today's open Swedish public-procurement tenders from `https://upphandling.mag.to/upphandlingar`, scores each one locally against the consultant's profile (role, domain, CPV codes, geography, language, blockers), then performs deep AI fit-analysis on the stretch+ matches using the consultant's CV and free-text preferences. Output is a single dated Markdown report in `$HOME` with one section per stretch+ tender (recommendation, fit_score, pros/cons in Swedish, the 1–3 best referensuppdrag from the CV) plus a table of low-fit tenders. On first run (or with `--reintake`) it walks a structured Q&A to produce `~/upphandling-profile.yaml`, `~/upphandling-cv.md`, and `~/upphandling-preferences.md`. All consultant data stays on the laptop — see Privacy.

## Files this skill manages

| Path | Purpose |
|---|---|
| `~/upphandling-profile.yaml` | Q&A answers + scoring weights (CPV, role, domain, blockers, geography) |
| `~/upphandling-cv.md` | CV in markdown — referensuppdrag source for enrichment |
| `~/upphandling-preferences.md` | Free-text "what I want next" — fed to the enrichment prompt |
| `~/upphandling-seen.json` | Map of `{id: iso_date}` for tenders already shown / enriched |
| `~/upphandling-matches-YYYY-MM-DD.md` | Daily report (one file per day) |
| `/tmp/upphandlingar.json` | Server fetch cache (transient) |
| `/tmp/scored.json` | Output of `score.py` (transient) |
| `/tmp/scored_with_enrichment.json` | Scored + enriched, fed to `render_report` (transient) |

## Process

### Step 0 — tooling sanity check

The skill needs `pyyaml` and `jinja2` to render templates and parse the profile.
Check both are importable; if not, install user-local (no sudo):

```bash
python3 -c "import yaml, jinja2" 2>/dev/null \
    || pip install --user pyyaml jinja2
```

Resolve the skill's own directory once — `score.py` and `intake.py` are sibling
files. Copilot CLI may invoke this skill from a discovered path, so try
`BASH_SOURCE` first then fall back to common install locations:

```bash
SKILL_DIR=""
for cand in \
    "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd)" \
    "$HOME/upphandling-matcher/client" \
    "$HOME/.copilot/skills/upphandling-matcher/client" \
    "$HOME/.claude/skills/upphandling-matcher/client"; do
    if [ -n "$cand" ] && [ -f "$cand/score.py" ] && [ -f "$cand/intake.py" ]; then
        SKILL_DIR="$cand"
        break
    fi
done
[ -z "$SKILL_DIR" ] && { echo "ERROR: cannot locate skill directory (score.py + intake.py)"; exit 1; }
export PYTHONPATH="$(dirname "$SKILL_DIR"):$PYTHONPATH"
```

`PYTHONPATH` is set so `from client.intake import …` works from anywhere.

### Step 1 — first-run intake (only if profile missing, or `--reintake`)

If `~/upphandling-profile.yaml` doesn't exist (or `--reintake`), walk this Q&A.
Otherwise skip to Step 2.

**1a. Ask the consultant to paste their CV.**

> "Klistra in ditt CV (markdown om möjligt — men jag tar vad du har).
> När du är klar, säg 'klart' eller bara skicka in det."

Save the CV text in memory; you'll write it to `~/upphandling-cv.md` after the
profile is approved.

**1b. Infer fields from the CV.**

Extract:
- `display_name` — full name from CV
- `years_experience` — count from earliest dated job to today
- `employee_level` — heuristic from titles + years:
  - mentions "chef", "manager", "head of" → `"Manager"`
  - mentions "lead", "principal", "tech lead" → `"Lead"`
  - 8+ years and senior titles → `"Senior"`
  - 3–7 years → `"Mid"`
  - <3 years → `"Junior"`
- `languages` — what the CV is written in + any languages explicitly listed
  (lowercase: `"swedish"`, `"english"`, etc.)

Show the inferred values and ASK the consultant to confirm or override:

> "Från CV:t läste jag av:
> - Namn: {display_name}
> - Erfarenhet: {years_experience} år
> - Nivå: {employee_level}
> - Språk: {languages}
>
> Stämmer det? Korrigera det som är fel."

**1c. Ask the gap questions** (everything not in the CV):

> "Några saker CV:t inte berättar:
> 1. Kontorsadress (gata + ort + postnummer) — för att räkna geografiavstånd
> 2. Max enkel pendling (km eller minuter)
> 3. OK med distans/hybrid? (ja/nej)
> 4. Top 3–5 konsultroller du skulle lämna anbud på (t.ex. 'IT-projektledare', 'lösningsarkitekt')
> 5. Top 3–5 domäner/branscher du har djup erfarenhet av (t.ex. 'transport', 'försäkring', 'offentlig sektor')
> 6. Något du absolut INTE vill se? (junior-roller, SAP, praktikplatser, …)
> 7. Min-score för djupanalys (default 8)"

Wait for answers. Then ask CPV calibration in a follow-up — show them the CPV
table from `${SKILL_DIR}/reference.md` (read it with the Read tool) and ask:

> "Här är CPV-koderna jag har på lager. Plocka ut:
> - 3–5 du vill prioritera (+3 till +5)
> - 0–3 du vill blocka (-5 till -10)
> Jag lägger alltid till `'72': 1` som bredd-IT så du inte missar generella IT-utlysningar."

**1d. Geocode the office address** via Nominatim (no API key, no auth):

```bash
ADDR_TEXT="<street, postal, city as the consultant typed it>"
ADDR=$(python3 -c "import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1]))" "$ADDR_TEXT")
curl -s "https://nominatim.openstreetmap.org/search?format=json&q=${ADDR}&limit=1" \
    -H "User-Agent: upphandling-matcher-intake" | \
    python3 -c "import json,sys; r=json.load(sys.stdin); print(r[0]['lat'], r[0]['lon']) if r else print('NOT FOUND')"
```

Show the consultant the resulting `lat lon` and ask for a one-line spot-check
("OK" / "fel — det här är inte Stockholm City"). Don't proceed without that
sanity check; bad coords ruin distance scoring silently.

**1e. Build the answers dict.**

Construct a single dict matching `client/templates/profile.yaml.j2`:

| Key | Source | Default |
|---|---|---|
| `display_name` | from CV | — |
| `employee_level` | inferred + confirmed | — |
| `years_experience` | inferred from CV | — |
| `office_address` | asked | — |
| `office_lat` | geocoded | — |
| `office_lon` | geocoded | — |
| `distance_bands_km` | derived from max-commute answer | `[20, 45, 80, 120]` |
| `distance_band_pts` | — | `[3, 2, 1, 0, -2]` |
| `languages` | confirmed from CV | `["swedish"]` |
| `wants_remote` | asked | `false` |
| `role_head` | asked, weights 3–5, **always pair noun + gerund Swedish forms** | — |
| `domain_boost` | asked, weights 1–4 | — |
| `blockers` | asked + sane defaults below | see below |
| `cpv_boost` | asked, always include `"72": 1` | `{"72": 1}` |
| `enrich_min_score` | asked, default `6` for procurement context | `6` |

**Swedish noun ↔ gerund pairing for `role_head` (this matters).** Procurement
tender titles overwhelmingly use the gerund form (`projektledning`,
`programledning`, `förändringsledning`), while consultants instinctively
write the noun form (`projektledare`, `programledare`, `förändringsledare`).
The scorer does substring matching, so `"projektledare"` does NOT match
`"projektledning"` and a strong tender will be silently underscored. **Always
add both forms** when seeding `role_head` from a consultant's job titles:

| Noun (consultant says) | Gerund (tender says) |
|---|---|
| `projektledare` | `projektledning` |
| `programledare` | `programledning` |
| `förändringsledare` | `förändringsledning` |
| `uppdragsledare` | `uppdragsledning` |
| `produktägare` | `produktägarskap` |
| `verksamhetsutvecklare` | `verksamhetsutveckling` |

Also add an `it-projektled` entry (note the truncated stem) which substring-matches
both `it-projektledare` and `it-projektledning`.

**Why `enrich_min_score: 6` (not 8 like jobs-dashboard):** Mercell tender
descriptions are typically much shorter than JobTech job ads (often 100–500
chars vs 2000+ chars for a job ad), so role/domain keywords get fewer
opportunities to fire. Empirically (one consultant, 83 fetched, late April
2026) only 1 tender broke 8 while the score-6 band held genuine fits worth
surfacing. Raise to 8 only if the consultant is drowning in noise.

**Sensible blocker defaults** for senior consultants (offer these unprompted,
let the consultant remove any they're fine with):

```python
[
    [r"\bjunior\b",        -8],
    [r"\bpraktik(ant)?\b", -10],
    [r"\bstudent\b",       -5],
    [r"\btrainee\b",       -5],
    [r"\bintern(ship)?\b", -8],
    ["sommarjobb",         -10],
]
```

If the consultant says "no SAP", add `[r"\bSAP\b", -6]`.

**1f. Render and validate the profile, then write the three files.**

```bash
python3 - <<'PY'
import json, os
from pathlib import Path
from client.intake import render_profile, validate_profile

answers = json.loads(os.environ["UPM_ANSWERS"])
text = render_profile(answers)
validate_profile(text)  # raises if any required key missing
out = Path(os.path.expanduser("~/upphandling-profile.yaml"))
out.write_text(text, encoding="utf-8")
print(f"wrote {out}")
print("--- rendered profile ---")
print(text)
PY
```

(Pass `answers` via `UPM_ANSWERS` env var — JSON-encoded — so the shell
doesn't try to interpret quotes inside the dict.)

Print the rendered YAML to the consultant and ASK for confirmation:

> "Så här blev profilen. Ser det rätt ut? (ja/justera)"

Once approved, write the two free-text files:

- `~/upphandling-cv.md` — the verbatim CV markdown the consultant pasted
- `~/upphandling-preferences.md` — 3–4 short paragraphs distilled from the
  intake answers (a brief from a recruiter, not a template). Cover:
  *Targeting* (what role they want next + level), *Domains of interest*
  (the domain_boost list as prose), *Not interested in* (blockers + CPV
  negatives as prose), *Languages & logistics* (commute, remote, languages).
  Same shape as `jobs-cv-intake`'s preferences.md.
- `~/upphandling-seen.json` — initialise with `{}`.

```bash
echo '{}' > ~/upphandling-seen.json
```

Then tell the consultant intake is done and they can re-run the skill (without
flags) to actually fetch tenders.

### Step 2 — fetch open tenders

```bash
URL="https://upphandling.mag.to/upphandlingar"
[ "$INCLUDE_CLOSED" = "1" ] && URL="${URL}?include_closed=true"

if curl -fsS "$URL" > /tmp/upphandlingar.json.new; then
    mv /tmp/upphandlingar.json.new /tmp/upphandlingar.json
else
    if [ -f /tmp/upphandlingar.json ]; then
        AGE=$(stat -f %Sm /tmp/upphandlingar.json 2>/dev/null \
              || stat -c %y /tmp/upphandlingar.json 2>/dev/null)
        echo "FETCH FAILED — using cache from $AGE"
    else
        echo "FETCH FAILED and no cache — aborting"
        exit 1
    fi
fi
```

Set `INCLUDE_CLOSED=1` if `--include-closed` flag was passed.

### Step 3 — score locally

```bash
python3 "${SKILL_DIR}/score.py" \
    /tmp/upphandlingar.json \
    "$HOME/upphandling-profile.yaml" \
    > /tmp/scored.json
```

If `score.py` exits non-zero, surface stderr verbatim and STOP. The most
common cause is a hand-edited `~/upphandling-profile.yaml` with broken YAML.
Show the consultant the error and suggest `/upphandling-match --reintake`
or a manual fix.

### Step 4 — split stretch+ vs low-fit, dedupe via seen.json

```python
import json, os
threshold = profile["enrich_min_score"]  # default 6 (procurement context)
seen = json.load(open(os.path.expanduser("~/upphandling-seen.json")))
scored = json.load(open("/tmp/scored.json"))

if FLAGS.all:
    stretch_plus = [u for u in scored if u["score"] >= threshold]
else:
    stretch_plus = [u for u in scored if u["score"] >= threshold and u["id"] not in seen]

low_fit = [u for u in scored if u["score"] < threshold]
stretch_plus.sort(key=lambda u: -u["score"])
low_fit.sort(key=lambda u: -u["score"])
```

`--all` re-shows previously-seen stretch+ items (useful after a profile change).

### Step 5 — enrich each stretch+ item (model does this inline)

For each item in `stretch_plus`, the model itself produces an `enrichment`
dict. The CV and preferences should be loaded ONCE into context (not re-read
per item):

```python
cv = open(os.path.expanduser("~/upphandling-cv.md")).read()
prefs = open(os.path.expanduser("~/upphandling-preferences.md")).read()
```

For each tender, generate this JSON object (model produces it directly,
no separate API call):

```json
{
  "recommendation": "bid|consider|skip",
  "fit_score": 1-10,
  "pros":  ["Svensk punkt 1", "Svensk punkt 2", ...],
  "cons":  ["Svensk punkt 1", "Svensk punkt 2", ...],
  "notes": "1-3 meningar svenska, fri text — t.ex. om kunden är kommunalt bolag, om ramavtal, etc.",
  "best_referenser": [
    "Projektnamn (årtal) — en rad varför det stödjer det här anbudet",
    "..."
  ]
}
```

Weighing for `recommendation`:

- **Role match** — does the consultant's title trajectory fit the
  konsultroll the tender asks for? Big factor.
- **Rate ceiling** — if the tender names a "takpris" / "maxkrona" and it
  sits below what `employee_level` typically bills at (rough guide:
  Senior 1100–1400, Lead 1300–1600, Manager 1500+ kr/h ex moms),
  surface as a `cons` line. Don't auto-skip — the consultant decides.
- **Required years** — if the tender names a minimum and
  `years_experience` is below it, surface as a `cons`.
- **Required certs** — extract any cert acronyms from the tender:
  PROPS, IPMA, ISTQB, AWS, Azure, Scrum (CSM, PSM, CSPO), ITIL, PRINCE2,
  PMP. If a cert is required and is absent from the CV, surface as a
  `cons`. If it's listed as "meriterande" only, mention in `notes`.
- **Geography** — distance from `office.address` to the tender's `region`.
  Already weighted into `score`, so this is just a sanity check; only
  mention in `cons` if the distance feels off (e.g. distansarbete possible
  but score still penalised it).
- **Customer sector** — is it a kommun / region / statlig myndighet /
  kommunalt bolag / privat? Note in `notes` if it's relevant
  (e.g. consultant said they prefer offentlig sektor).

Output Swedish for `pros`, `cons`, `notes`, and `best_referenser`.
`recommendation` is literally `"bid"` / `"consider"` / `"skip"` (English) so
the report template can switch on it cleanly. fit_score scale:
1–3 skip, 4–6 consider, 7–10 bid.

`best_referenser` MUST come from the CV. Pick 1–3 referensuppdrag that most
directly support a bid for this tender. Format each as
`"Projektnamn (years) — one-line why"`. If the CV doesn't have a strong
reference for this tender, use 1 partial match and note in `notes` that
the reference fit is thin.

Attach the dict under `enrichment` on each upphandling, then write the full
list to `/tmp/scored_with_enrichment.json`.

### Step 6 — render the report

```bash
DATE=$(date +%F)
python3 - <<PY
import json, os
from client.intake import render_report
data = json.load(open("/tmp/scored_with_enrichment.json"))
md = render_report(
    stretch_plus=data["stretch_plus"],
    low_fit=data["low_fit"],
    date="$DATE",
)
out = os.path.expanduser(f"~/upphandling-matches-$DATE.md")
open(out, "w", encoding="utf-8").write(md)
print(out)
PY
```

(`/tmp/scored_with_enrichment.json` is the model-built object with
top-level `stretch_plus` and `low_fit` lists.)

### Step 7 — update seen.json

For every stretch+ item that was rendered into today's report, mark it
seen with today's date. Don't add low-fit items — the rubric may improve
later, and those should re-surface if their score crosses the threshold.

```python
import json, os
from datetime import date
seen_path = os.path.expanduser("~/upphandling-seen.json")
seen = json.load(open(seen_path))
today = date.today().isoformat()
for item in stretch_plus:
    seen[item["id"]] = today
json.dump(seen, open(seen_path, "w"), ensure_ascii=False, indent=2)
```

### Step 8 — terminal summary

Print a compact summary to the consultant:

```
N stretch+ matches today, M below threshold.
Report written to ~/upphandling-matches-YYYY-MM-DD.md
Top 3:
  14p — Senior projektledare till digital transformation (Trafikverket, deadline 2026-05-15)
  12p — IT-arkitekt ramavtal (Region Stockholm, deadline 2026-05-22)
  10p — Lösningsarkitekt vården (Inera, deadline 2026-05-30)
```

If `N == 0`, say so explicitly and remind the consultant they can run
`--all` to re-show previously-seen items, or `--include-closed` to widen
the fetch.

## Flags

| Flag | Effect |
|---|---|
| `--reintake` | Force a fresh Q&A intake even if profile exists. Backs up the old profile to `~/upphandling-profile.yaml.bak` first; doesn't touch `~/upphandling-cv.md` or `~/upphandling-preferences.md` unless the consultant pastes new versions. |
| `--all` | Skip the seen.json dedupe — re-show every stretch+ tender, including ones already in the report. Useful after tweaking the profile. |
| `--include-closed` | Fetch closed tenders too (adds `?include_closed=true` to the URL). Useful for back-testing the rubric against historical data. |

Flags can be combined: `/upphandling-match --all --include-closed`.

## Privacy

The skill never uploads CV, profile, or any consultant data anywhere. All
enrichment runs in the same Copilot CLI session that's running this skill.
The only network calls are: a single GET to upphandling.mag.to for the
public open-tender list, and (during first-run intake only) a single GET
to nominatim.openstreetmap.org to geocode the office address.

`~/upphandling-profile.yaml`, `~/upphandling-cv.md`,
`~/upphandling-preferences.md`, and `~/upphandling-seen.json` live only on
the consultant's laptop. The daily report `~/upphandling-matches-YYYY-MM-DD.md`
likewise stays local — share it with colleagues by copy-paste, not by
syncing the file.

## Reference data

See `reference.md` in this skill directory for the CPV taxonomy
(IT-relevant prefixes with calibration cheatsheets for senior PM,
software dev, and UX designer archetypes).
