Verdicts drive the score: rejection excludes the contact, and is reversible #17

Merged
hermes-agent merged 8 commits from feature/verdict-drives-score into main 2026-10-29 18:50:06 +00:00
Owner

Owner follow-up on the verdict work in #16: the two dropdowns were bookkeeping only - the points calculation did not follow, and a rejected contact could not be approved back. Both fixed.

1. A rejection adjusts the calculation

Per-QSO findings now point at their contact: every per-QSO flag in the pure rules engine carries qso_index (MODE_NOT_ALLOWED, OUT_OF_TIME, locator flags, missing-field; DUP_BAND becomes one flag per excess logging, pointing at exactly that occurrence), the ingest persists it as flag.qso_id, and a verdict on such a flag excludes that contact from the score. The recomputation is not a second score model: it runs the same monthly_score engine over the surviving contacts, re-derives the multiplier marks over the survivors (a rejected row cannot hoard a multiplier), and updates the cached entry fields the public pages read.

Numbers on the live stack, corpus 17 freshly ingested: score 9 (3 x 3) → reject the DG flag → 4 (2 x 2 over the survivors) → approve it back → 9. Both changes audited with the toelichting as the reason.

2. Reversible

/manager/qso/{id}/verdict reaches the same mechanic from the row itself: a Goedgekeurd on an excluded contact restores it; a rejections chip (Afgekeurd + toelichting) shows on the row, controls only in the manager's drill-in (entrant views get the chip), and every change is one audit row. The flag verdict and the row verdict move the same boolean, so there is one source of truth.

Boundaries

  • Entry-level flags (the log's shape, INELIGIBLE_BAND) keep their verdict as bookkeeping - their score effect is ambiguous by nature and stays with the manual score tool (T5.2), per the never-auto-reject rule.
  • A field edit on a contact now also recomputes: it changes the basis (locators/mode) and the old cached score would silently drift - caught in an existing test that now asserts the recomputed value.

Migrations 0011 (qso.excluded_by_manager) + 0012 (qso.verdict_note), both applied and verified on the live Postgres. Tests: 318.

Notes: flags created before this change (existing entries) carry no contact link - their verdicts stay bookkeeping until a re-upload regenerates the log (flags are rebuilt per version).

Owner follow-up on the verdict work in #16: the two dropdowns were bookkeeping only - the points calculation did not follow, and a rejected contact could not be approved back. Both fixed. ## 1. A rejection adjusts the calculation Per-QSO findings now *point at their contact*: every per-QSO flag in the pure rules engine carries `qso_index` (MODE_NOT_ALLOWED, OUT_OF_TIME, locator flags, missing-field; DUP_BAND becomes **one flag per excess logging**, pointing at exactly that occurrence), the ingest persists it as `flag.qso_id`, and a verdict on such a flag **excludes that contact from the score**. The recomputation is not a second score model: it runs the same `monthly_score` engine over the surviving contacts, re-derives the multiplier marks over the survivors (a rejected row cannot hoard a multiplier), and updates the cached entry fields the public pages read. Numbers on the live stack, corpus 17 freshly ingested: score 9 (3 x 3) → reject the DG flag → **4** (2 x 2 over the survivors) → approve it back → **9**. Both changes audited with the toelichting as the reason. ## 2. Reversible `/manager/qso/{id}/verdict` reaches the same mechanic from the **row itself**: a Goedgekeurd on an excluded contact restores it; a rejections chip (Afgekeurd + toelichting) shows on the row, controls only in the manager's drill-in (entrant views get the chip), and every change is one audit row. The flag verdict and the row verdict move the same boolean, so there is one source of truth. ## Boundaries - Entry-level flags (the log's shape, INELIGIBLE_BAND) keep their verdict as **bookkeeping** - their score effect is ambiguous by nature and stays with the manual score tool (T5.2), per the never-auto-reject rule. - A field edit on a contact now also recomputes: it changes the basis (locators/mode) and the old cached score would silently drift - caught in an existing test that now asserts the recomputed value. Migrations 0011 (`qso.excluded_by_manager`) + 0012 (`qso.verdict_note`), both applied and verified on the live Postgres. Tests: **318**. Notes: flags created before this change (existing entries) carry no contact link - their verdicts stay bookkeeping until a re-upload regenerates the log (flags are rebuilt per version).
The manager's entry drill-in showed no mode, so the mode could not be controlled
there at all. The shared per-QSO table (preview, confirmation, manager drill-in and
the live sessions view) gets a Mode column fed by a display helper:

- The mode is rendered in the reglement's own vocabulary: FM, SSB, CW. SSB's
  emission labels USB/LSB are ACCEPTED and read as "SSB" (owner: do not disapprove
  them), the same normalisation the flag layer and the live logger apply - so a
  logged USB is an ordinary SSB row, no finding.
- EDI's numeric mode codes never reach the table (1/2/6 are translated at parse
  time); a mode outside the reglement keeps its RAW value ("DG", "RY") with a
  MODE_NOT_ALLOWED marker on that row, so the manager can adjudicate what was
  actually logged.
- A blank mode renders as an em dash - EDI allows an empty mode column by the
  standard, so absence is not a finding.

Verified on the running stack: entry 1 (PD1SCO) shows FM/SSB per row; entry 4
(PD9MODE) keeps DG and RY with the marker. Tests: 312 (5 new).
On the manager's entry page the two free-text boxes under an attention point made
no sense as a workflow - the owner called it out: a toelichting and a reden, with
no decision, means the point is never handled. Looking is not deciding.

- The Flag gains a disposition: approved or rejected, NULL while open. Postgres
  enum type flagdisposition (migration 0010; the type is created and referenced
  per the SAEnum lesson, so SQLite's create_all and the real database agree).
- The resolve form is now: a required decision dropdown (Kies + Goedgekeurd +
  Afgekeurd) and one optional toelichting. The second box is gone; its audit role
  moves to the toelichting, which the flag.resolve audit row stores as the reason.
- No disposition, no resolution: an empty choice is refused (redirect, flag stays
  open, no audit row) and the UI demands the dropdown.
- A verdict is bookkeeping, not scoring: a rejection changes no score; the
  manual score-adjustment tool (T5.2) remains where consequences live.
- Existing resolutions (resolved_by_manager without a disposition) read as
  afgehandeld as before; new ones always carry the verdict.

Verified on the running stack with entry 4's two MODE_NOT_ALLOWED flags: one
rejected with the toelichting stored (disposition=rejected, resolved, note on the
page), the other untouched; an empty verdict refused. Tests: 315 (3 new, 1
updated to the verdict API).
Owner follow-up on the verdict dropdown: rejecting an attention point must adjust
the score automatically (it did nothing to the calculation), and a rejected contact
must be approvable again - the manager is human.

- app/rules/flags.py: every per-QSO flag now carries qso_index (the row it is
  about) in the pure computation; DUP_BAND becomes one flag per excess logging and
  points exactly at that occurrence, so a verdict rejects the dupe instead of the
  vague pair. INELIGIBLE_BAND and the section-shape flags stay entry-level.
- ingest: Flag rows persist qso_id (flushed before the flag build, both on the
  merge and first-submit paths; the flush sits inside the IntegrityError guard
  because the entry insert happens there - caught by the race test).
- Migration 0011 qso.excluded_by_manager + 0012 qso.verdict_note.
- app/persistence/adjudicate.py: recompute_entry_score runs the SAME monthly_score
  engine over the surviving contacts (excluded rows score nothing, multiplier
  marks are re-derived over the survivors - a rejected row cannot hoard a
  multiplier); apply_flag_verdict / apply_qso_verdict set the verdict, recompute,
  and audit once, with the toelichting as the reason.
- Routes: flag verdicts follow through to the contact; a per-contact verdict route
  /manager/qso/{id}/verdict approves or rejects from the row itself; the drill-in
  has the controls and chips (entrant views show chips only); a field edit now
  also recomputes, since it changes the score's basis.
- Deliberately NOT in scope: entry-level flags (shape of the log, INELIGIBLE_BAND)
  keep their verdict as bookkeeping; consequences stay on the manual score tool.

Verified on the running stack with a fresh ingest of corpus 17 (flags linked to
their contacts): reject the DG flag -> score 9 -> 4 (2 points x 2 multipliers over
the survivors); approve it back from the row -> 9 restored; both verdicts audited.
Tests: 318.
Owner finding on the verdict work: approving a rejected contact from the row
restored the points correctly, but the attention point above kept saying
Afgekeurd - even after a reload. The two verdict stores were only synced one way
(flag -> row); the row -> flag direction was missing, and old ingests stored
per-QSO findings WITHOUT the contact link at all, so no sync could ever reach
them.

- heal_flag_links: orphan per-QSO flags (MODE_NOT_ALLOWED, OUT_OF_TIME, locator
  flags, ADIF missing-field) are linked to the contact their detail names, on
  every view of the entry detail and before every verdict. Matching is strict:
  one unambiguous match links, anything else (a detail naming two rows, or a
  call appearing on two bands) stays bookkeeping rather than guessing.
- Row verdicts now reflect into every attention point about that contact: the
  flag takes the verdict, the note and the handled state, so the list above the
  table cannot disagree with the table again.

Verified live: approving the rejected DG contact from the row flips its attention
point to Goedgekeurd on reload, score restored to 9, the RY point stays open.
Tests: 321 (3 new).
The heal fixed orphan flags but skipped already-linked ones, so a flag linked in
an earlier view could still say Afgekeurd while its contact was approved again
(the owner's exact screenshot). The contradiction check is its own pass now and
runs on every view for every per-QSO flag: the contact's state is the scoring
truth, so a stale rejection flips to approved (with the contact's toelichting),
an exclusion adopts rejected, and a never-excluded contact leaves an open flag
open - 'not excluded' is not a verdict.

Verified live on entry 4: the stale pair now reads approved/open, matching the
rows and the restored score of 9. Tests: 322.
Owner ruling after entry-3 testing: two verdict systems exist and therefore
disagree - the point said Afgekeurd while its contact was approved again, and a
point-level decision on an entry-level flag did nothing to the row. The rule:
ATTENTION POINTS ARE OBSERVATIONS. The logged QSOs carry the verdict; that is the
truth.

- The flag's verdict columns and the flag.resolve route are gone (migration 0013
  drops disposition/resolution_note/resolved_by_manager; the audit history of the
  removed action stays). FlagDisposition is removed; the verdict vocabulary lives
  as adjudicate.Verdict.
- The attention point's displayed state is DERIVED at render time from the contacts
  it is about: one linked contact -> its verdict + toelichting; INELIGIBLE_BAND ->
  the state of its band-NULL rows (including a deels-afgekeurd case); a flag about
  the log's shape -> 'Observatie', consequences via the manual score tool.
- heal_flag_links keeps linking orphan per-QSO findings to their (single unambiguous)
  contact so the derived display is correct for old ingests; it writes no verdict.
- The dashboard's per-entry column counts afgekeurde contacten (the honest
  single-truth measure) instead of unresolved flags.
- The row-level verdict form (already in the table) is now the only verdict surface,
  manager-only, reversible, audited; field edits recompute too.

Verified live: entry 4's stale contradiction reads approved/open as the rows do;
entry 3's PA1HF verdict lives in the row. Tests: 321.
Owner rule: the Contest Manager may change a QSO (e.g. assign the correct band to
a wrong-band contact) through the regular edit path, and every change names its
reason - nothing may be modified silently.

- /qso/{id}/edit: reason required server-side (empty -> back with ?reden=1
  showing the notice; no field change, no audit row) + required in the form.
- /entry/{id}/score (adjust score) and /entry/{id}/reset-pin: the same rule.
- /entry/{id}/override: was required in the template, now enforced server-side
  too, so the browser can not be the only guard.
- i18n: manager_reason_required (nl + en); the notice renders on ?reden=1.

The verdicts are untouched: a verdict is itself the explicit decision (dropdown),
its note stays optional. Tests: 325.
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
public/vrza-contestrobot!17
No description provided.