Task 13 — POST /api/feedback endpoint + FeedbackRepo + incremental Naive Bayes retraining Date: 2026-09-01 What was built: - crates/news-store/src/feedback.rs (NEW) - FeedbackRepo(SqlitePool) - insert(&Feedback) -> Result<(), StoreError> - cluster_exists(Uuid) -> Result - canonical_item_text(Uuid) -> Result, StoreError> - tests: insert_round_trips_feedback_row, cluster_exists_true_for_existing_cluster, cluster_exists_false_for_unknown_cluster, canonical_item_text_returns_title_and_summary_for_cluster, canonical_item_text_returns_none_for_unknown_cluster - crates/news-store/src/lib.rs: pub mod feedback + pub use feedback::FeedbackRepo - crates/news-server/src/api/mod.rs (NEW): pub mod feedback - crates/news-server/src/api/feedback.rs (NEW) - FeedbackState { pool: SqlitePool } - FeedbackRequest { story_cluster_id: Uuid, kind: FeedbackKind } - router(pool) -> Router with POST /api/feedback - post_feedback handler: cluster existence check (400), canonical text lookup, feedback row insert, incremental RelevanceScorer::load(...).train(tokens, kind), returns 200 {"status":"recorded"} - tests: unknown_cluster_id_returns_400, malformed_kind_returns_422, valid_feedback_records_row_and_trains_model (oneshot, no listener) - crates/news-server/src/lib.rs: pub mod api - root Cargo.toml [workspace.dependencies]: axum = "0.8", tower = { version = "0.4", features = ["util"] } - crates/news-server/Cargo.toml: axum = { workspace = true }, tower = { workspace = true } (dev-dependencies) Verification commands: --- cargo fmt --all --check --- (no output, exit 0) --- cargo clippy --workspace --all-targets -- -D warnings --- Finished `dev` profile [unoptimized+debuginfo] target(s) in 0.25s (exit 0) --- cargo test -p news-server --- running 14 tests test api::feedback::tests::unknown_cluster_id_returns_400 ... ok test api::feedback::tests::malformed_kind_returns_422 ... ok test api::feedback::tests::valid_feedback_records_row_and_trains_model ... ok test gate::tests::below_band_suppresses_below_threshold ... ok test gate::tests::digest_band_returns_digest ... ok test gate::tests::already_notified_suppresses_when_delta_not_reached ... ok test gate::tests::quiet_hours_suppresses_high_relevance ... ok test gate::tests::happy_path_notifies_when_relevance_and_budget_allow ... ok test gate::tests::material_update_notifies_again_when_delta_reached ... ok test gate::tests::bypass_ceiling_is_independent_of_normal_budget ... ok test scoring::trained_model_persists_and_reloads_with_same_total_samples ... ok test scoring::matching_item_scores_higher_than_non_matching ... ok test scoring::importance_scorer_matches_direct_formula ... ok test scoring::blocklist_veto_returns_neg_infinity ... ok test result: ok. 14 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s (exit 0) --- cargo test -p news-store --- running 22 tests test feedback::tests::insert_round_trips_feedback_row ... ok test feedback::tests::cluster_exists_true_for_existing_cluster ... ok test feedback::tests::cluster_exists_false_for_unknown_cluster ... ok test feedback::tests::canonical_item_text_returns_title_and_summary_for_cluster ... ok test feedback::tests::canonical_item_text_returns_none_for_unknown_cluster ... ok ... (other existing tests) ... test result: ok. 22 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s (exit 0) New test names: - news-store::feedback::tests::insert_round_trips_feedback_row - news-store::feedback::tests::cluster_exists_true_for_existing_cluster - news-store::feedback::tests::cluster_exists_false_for_unknown_cluster - news-store::feedback::tests::canonical_item_text_returns_title_and_summary_for_cluster - news-store::feedback::tests::canonical_item_text_returns_none_for_unknown_cluster - news-server::api::feedback::tests::unknown_cluster_id_returns_400 - news-server::api::feedback::tests::malformed_kind_returns_422 - news-server::api::feedback::tests::valid_feedback_records_row_and_trains_model