KeiSeiKit-1.0/_primitives/_rust/frustration-matrix/src/report.rs
Parfii-bot 0be354a920 KeiSeiKit-public — clean state
Single-commit clean baseline after security scrub of niche-tells,
project codenames, internal jargon, and contributor-email leaks.

Contents:
- 100 Rust crates (_primitives/_rust/)
- 37 agent manifests (_manifests/) + generated specs (_generated/)
- 67 user-invocable skills (skills/)
- 33 hooks (hooks/)
- Composition blocks (_blocks/)
- Documentation (docs/, README.md)
- TS adapter packages (_ts_packages/)
- Assembler (_assembler/)
- Roles (_roles/)
- Templates (_templates/)
- Forgejo CI (.forgejo/)

Author: Denis Parfionovich <info@greendragon.info>

License: see LICENSE.
2026-05-01 12:09:03 +08:00

114 lines
3.3 KiB
Rust

//! Report — read scan output, aggregate, print plaintext table.
//!
//! Constructor Pattern: one responsibility = turn rows into a ranking.
//! Two group-by modes: `category` (default) and `session` (chatlog file).
//! Sort key = weighted score (count * weight), desc.
//!
//! Output is plaintext with fixed-width columns so the user can grep it.
use crate::row::{parse_csv, Row};
use anyhow::{Context, Result};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
/// Group-by mode.
#[derive(Copy, Clone, Debug)]
pub enum GroupBy {
Category,
Session,
}
#[derive(Debug, Clone)]
pub struct Aggregate {
pub key: String,
pub count: usize,
pub weighted: f64,
pub top_example: String,
}
/// CLI entry. Reads rows from `input`, prints the top-N table.
pub fn run(input: &Path, top: usize, by: GroupBy) -> Result<()> {
let rows = read_rows(input)?;
let mut aggs = aggregate(&rows, by);
sort_desc(&mut aggs);
print_table(&aggs, top, by);
Ok(())
}
fn read_rows(input: &Path) -> Result<Vec<Row>> {
let body = fs::read_to_string(input)
.with_context(|| format!("read {}", input.display()))?;
if looks_like_jsonl(&body) {
body.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| serde_json::from_str::<Row>(l).context("parse jsonl row"))
.collect()
} else {
parse_csv(&body)
}
}
fn looks_like_jsonl(body: &str) -> bool {
body.trim_start().starts_with('{')
}
/// Aggregate rows by the chosen key. `top_example` is the first quote
/// encountered in the group; the goal is to show a concrete sample row
/// the reviewer can grep for, not a statistical "representative".
pub fn aggregate(rows: &[Row], by: GroupBy) -> Vec<Aggregate> {
let mut map: HashMap<String, Aggregate> = HashMap::new();
for r in rows {
let key = match by {
GroupBy::Category => r.category.clone(),
GroupBy::Session => r.chatlog_file.clone(),
};
let slot = map.entry(key.clone()).or_insert(Aggregate {
key,
count: 0,
weighted: 0.0,
top_example: r.quote.clone(),
});
slot.count += 1;
slot.weighted += r.weight;
}
map.into_values().collect()
}
fn sort_desc(aggs: &mut [Aggregate]) {
aggs.sort_by(|a, b| {
b.weighted
.partial_cmp(&a.weighted)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| b.count.cmp(&a.count))
.then_with(|| a.key.cmp(&b.key))
});
}
fn print_table(aggs: &[Aggregate], top: usize, by: GroupBy) {
let hdr = match by {
GroupBy::Category => "CATEGORY",
GroupBy::Session => "SESSION",
};
println!("{:<28} {:>6} {:>9} TOP_EXAMPLE", hdr, "COUNT", "WEIGHTED");
for a in aggs.iter().take(top) {
let key = clip(&a.key, 28);
let ex = clip(&a.top_example, 60);
println!(
"{:<28} {:>6} {:>9.1} {}",
key, a.count, a.weighted, ex
);
}
if aggs.is_empty() {
println!("(no rows)");
}
}
/// Clip long strings for table cells.
fn clip(s: &str, n: usize) -> String {
if s.chars().count() <= n {
return s.to_string();
}
let trimmed: String = s.chars().take(n.saturating_sub(1)).collect();
format!("{trimmed}")
}