1use anyhow::{Result, bail};
11use clap::{Args, Subcommand};
12use uuid::Uuid;
13
14use agentstatedeveloper_core::{
15 AsgFeedbackStore, AsgIndexStore, AsgLedgerStore, Author, AuthorKind, Engine, FeedbackEntry,
16 FeedbackStore, FeedbackVerdict, IndexStore, LedgerEntry, LedgerKind, LedgerStore, RoleTag,
17};
18
19use crate::config::Config;
20
21#[derive(Debug, Subcommand)]
22pub enum FeedbackCmd {
23 Mark(MarkArgs),
25 List(ListArgs),
27 Expire(ExpireArgs),
29 Withdraw(WithdrawArgs),
31 Purge(PurgeArgs),
33 PromoteAsTruth(PromoteAsTruthArgs),
35 Export(ExportArgs),
37 Import(ImportArgs),
39}
40
41#[derive(Debug, Args)]
42pub struct MarkArgs {
43 pub query: String,
45 pub qname: String,
47 pub verdict: String,
49 #[arg(long)]
51 pub note: Option<String>,
52 #[arg(long, default_value = "asd-cli")]
54 pub author: String,
55 #[arg(long)]
57 pub file_scope: Option<String>,
58 #[arg(long)]
63 pub covered_by: Option<String>,
64
65 #[arg(long)]
72 pub ttl_days: Option<i64>,
73}
74
75#[derive(Debug, Args)]
76pub struct ListArgs {
77 pub qname: Option<String>,
79 #[arg(long)]
81 pub json: bool,
82}
83
84#[derive(Debug, Args)]
87pub struct ExpireArgs {
88 pub entry_id: Option<String>,
90
91 #[arg(long, conflicts_with = "entry_id")]
93 pub symbol: Option<String>,
94
95 #[arg(long, conflicts_with_all = ["entry_id", "symbol"])]
98 pub query: Option<String>,
99
100 #[arg(long)]
102 pub dry_run: bool,
103
104 #[arg(long)]
106 pub json: bool,
107}
108
109#[derive(Debug, Args)]
110pub struct WithdrawArgs {
111 pub entry_id: String,
113
114 #[arg(long, default_value = "asd-cli")]
116 pub by: String,
117
118 #[arg(long)]
120 pub reason: Option<String>,
121
122 #[arg(long)]
124 pub json: bool,
125}
126
127#[derive(Debug, Args)]
128pub struct PurgeArgs {
129 pub entry_id: String,
131
132 #[arg(long)]
134 pub yes: bool,
135
136 #[arg(long)]
138 pub json: bool,
139}
140
141#[derive(Debug, Args)]
142pub struct PromoteAsTruthArgs {
143 pub qname: String,
145 #[arg(long)]
147 pub concept: String,
148 #[arg(long, default_value = "asd-cli")]
150 pub author: String,
151}
152
153#[derive(Debug, Args)]
154pub struct ExportArgs {
155 #[arg(long)]
157 pub output: Option<String>,
158 #[arg(long)]
160 pub summary: bool,
161}
162
163#[derive(Debug, Args)]
164pub struct ImportArgs {
165 #[arg(long)]
167 pub input: Option<String>,
168 #[arg(long, default_value = "true")]
170 pub skip_existing: bool,
171 #[arg(long, default_value = "asd-import")]
173 pub author: String,
174 #[arg(long)]
176 pub dry_run: bool,
177}
178
179pub fn run(cfg: &Config, cmd: FeedbackCmd) -> Result<()> {
180 match cmd {
181 FeedbackCmd::Mark(args) => run_mark(cfg, args),
182 FeedbackCmd::List(args) => run_list(cfg, args),
183 FeedbackCmd::Expire(args) => run_expire(cfg, args),
184 FeedbackCmd::Withdraw(args) => run_withdraw(cfg, args),
185 FeedbackCmd::Purge(args) => run_purge(cfg, args),
186 FeedbackCmd::PromoteAsTruth(args) => run_promote_as_truth(cfg, args),
187 FeedbackCmd::Export(args) => run_export(cfg, args),
188 FeedbackCmd::Import(args) => run_import(cfg, args),
189 }
190}
191
192fn parse_verdict(s: &str) -> Result<FeedbackVerdict> {
193 if let Some(v) = FeedbackVerdict::from_str(s) {
196 return Ok(v);
197 }
198 bail!(
199 "unknown verdict {:?}; valid: useful, noisy, missing, wrong_layer, already_covered, diagnostic_only",
200 s
201 )
202}
203
204fn run_mark(cfg: &Config, args: MarkArgs) -> Result<()> {
205 let verdict = parse_verdict(&args.verdict)?;
206 let engine = Engine::open_sqlite(&cfg.db_path)?;
207 let (symbol_id, symbol_qname) = if let Some(ref glob) = args.file_scope {
208 (
210 format!("__file_scope__{}", Uuid::new_v4().simple()),
211 glob.clone(),
212 )
213 } else {
214 let index_store = AsgIndexStore::from_engine(&engine);
215 let symbol = match index_store.get_symbol_by_qname(&engine.ref_name, &args.qname)? {
216 Some(s) => s,
217 None => bail!("symbol not found: {}", args.qname),
218 };
219 (symbol.symbol_id, args.qname.clone())
220 };
221 let now = chrono::Utc::now();
222 let expires_at = args.ttl_days.map(|days| now + chrono::Duration::days(days));
223 let entry = FeedbackEntry {
224 entry_id: format!("fb_{}", Uuid::new_v4().simple()),
225 symbol_id,
226 symbol_qname: symbol_qname.clone(),
227 query: args.query.to_lowercase().trim().to_string(),
228 verdict,
229 note: args.note.clone(),
230 author: args.author.clone(),
231 created_at: now,
232 file_scope: args.file_scope.clone(),
233 expires_at,
234 withdrawn_at: None,
236 withdrawn_by: None,
237 withdrawn_reason: None,
238 };
239 let feedback_store = AsgFeedbackStore::from_engine(&engine);
240 feedback_store.record(&engine.ref_name, &entry, &args.author)?;
241
242 let mut paired_msg = String::new();
248 if args.file_scope.is_none() {
249 let author_kind = if args.author == "asd-cli" {
250 AuthorKind::Human
251 } else {
252 AuthorKind::Agent
253 };
254 let author_struct = Author {
255 kind: author_kind,
256 id: args.author.clone(),
257 };
258 let ledger_store = AsgLedgerStore::from_engine(&engine);
259
260 if matches!(verdict, FeedbackVerdict::AlreadyCovered) {
261 let cover = args.covered_by.as_deref().ok_or_else(|| {
262 anyhow::anyhow!("--covered-by <qname> is required when --verdict already_covered")
263 })?;
264 let body = serde_json::json!({
265 "from_qname": &args.qname,
266 "to_qname": cover,
267 "source": "feedback-pair",
268 })
269 .to_string();
270 let mut led = LedgerEntry::new(
271 &entry.symbol_id,
272 LedgerKind::Mapping,
273 format!("covered by {cover}"),
274 author_struct.clone(),
275 );
276 led.body = Some(body);
277 led.tags.push("plan-e:t-009".into());
278 ledger_store.append_entry(&engine.ref_name, &led, &args.author)?;
279 paired_msg = format!(" + Mapping ledger entry → {cover}");
280 } else if matches!(verdict, FeedbackVerdict::DiagnosticOnly) {
281 let mut led = LedgerEntry::new(
282 &entry.symbol_id,
283 LedgerKind::Ownership,
284 format!("diagnostic-only: {}", args.query),
285 author_struct.clone(),
286 );
287 led.role = Some(RoleTag::DiagnosticTest.as_str().to_string());
288 led.tags.push("plan-e:t-009".into());
289 ledger_store.append_entry(&engine.ref_name, &led, &args.author)?;
290 paired_msg = " + Classification ledger entry (role=diagnostic-test)".to_string();
291 }
292 }
293
294 if args.file_scope.is_some() {
295 println!(
296 "recorded {} for files matching {:?} ({})",
297 args.verdict, symbol_qname, entry.entry_id
298 );
299 } else {
300 println!(
301 "recorded {} for {} ({}){}",
302 args.verdict, args.qname, entry.entry_id, paired_msg
303 );
304 }
305 Ok(())
306}
307
308fn run_promote_as_truth(cfg: &Config, args: PromoteAsTruthArgs) -> Result<()> {
309 let engine = Engine::open_sqlite(&cfg.db_path)?;
310 let index_store = AsgIndexStore::from_engine(&engine);
311 let symbol = match index_store.get_symbol_by_qname(&engine.ref_name, &args.qname)? {
312 Some(s) => s,
313 None => bail!("symbol not found: {}", args.qname),
314 };
315 let author_kind = if args.author == "asd-cli" {
316 AuthorKind::Human
317 } else {
318 AuthorKind::Agent
319 };
320 let mut entry = LedgerEntry::new(
321 &symbol.symbol_id,
322 LedgerKind::Ownership,
323 &args.concept,
324 Author {
325 kind: author_kind,
326 id: args.author.clone(),
327 },
328 );
329 entry.tags = vec!["promote-as-truth".to_string()];
330 let ledger_store = AsgLedgerStore::from_engine(&engine);
331 ledger_store.append_entry(&engine.ref_name, &entry, &args.author)?;
332 println!(
333 "promoted {} as source-of-truth for \"{}\" ({})",
334 args.qname, args.concept, entry.entry_id
335 );
336 Ok(())
337}
338
339fn run_list(cfg: &Config, args: ListArgs) -> Result<()> {
340 let engine = Engine::open_sqlite(&cfg.db_path)?;
341 let feedback_store = AsgFeedbackStore::from_engine(&engine);
342 let entries = if let Some(ref qname) = args.qname {
343 let index_store = AsgIndexStore::from_engine(&engine);
344 match index_store.get_symbol_by_qname(&engine.ref_name, qname)? {
345 Some(sym) => feedback_store.list_for_symbol(&engine.ref_name, &sym.symbol_id)?,
346 None => bail!("symbol not found: {}", qname),
347 }
348 } else {
349 feedback_store.list_all(&engine.ref_name)?
350 };
351
352 if args.json {
353 println!("{}", serde_json::to_string_pretty(&entries)?);
354 return Ok(());
355 }
356
357 if entries.is_empty() {
358 println!("no feedback recorded");
359 return Ok(());
360 }
361
362 for e in &entries {
363 println!(
364 "[{}] {:?} {} query={:?} author={}",
365 e.entry_id, e.verdict, e.symbol_qname, e.query, e.author
366 );
367 if let Some(ref note) = e.note {
368 println!(" note: {}", note);
369 }
370 }
371 Ok(())
372}
373
374fn verdict_breakdown(entries: &[FeedbackEntry]) -> (usize, usize, usize, usize) {
375 let useful = entries
376 .iter()
377 .filter(|e| matches!(e.verdict, FeedbackVerdict::Useful))
378 .count();
379 let noisy = entries
380 .iter()
381 .filter(|e| matches!(e.verdict, FeedbackVerdict::Noisy))
382 .count();
383 let missing = entries
384 .iter()
385 .filter(|e| matches!(e.verdict, FeedbackVerdict::Missing))
386 .count();
387 let wl = entries
388 .iter()
389 .filter(|e| matches!(e.verdict, FeedbackVerdict::WrongLayer))
390 .count();
391 (useful, noisy, missing, wl)
392}
393
394fn run_export(cfg: &Config, args: ExportArgs) -> Result<()> {
395 let engine = Engine::open_sqlite(&cfg.db_path)?;
396 let feedback_store = AsgFeedbackStore::from_engine(&engine);
397 let entries = feedback_store.list_all(&engine.ref_name)?;
398
399 if args.summary {
400 let (useful, noisy, missing, wl) = verdict_breakdown(&entries);
401 println!(
402 "{}",
403 serde_json::to_string_pretty(&serde_json::json!({
404 "total": entries.len(),
405 "by_verdict": {
406 "useful": useful,
407 "noisy": noisy,
408 "missing": missing,
409 "wrong_layer": wl,
410 },
411 "db": cfg.db_path.display().to_string(),
412 }))?
413 );
414 return Ok(());
415 }
416
417 let json = serde_json::to_string_pretty(&entries)?;
418 let (useful, noisy, missing, wl) = verdict_breakdown(&entries);
419 match args.output {
420 Some(ref path) => {
421 std::fs::write(path, &json)?;
422 eprintln!(
423 "asd: exported {} feedback entries to {} (useful={}, noisy={}, missing={}, wrong_layer={})",
424 entries.len(),
425 path,
426 useful,
427 noisy,
428 missing,
429 wl
430 );
431 }
432 None => println!("{}", json),
433 }
434 Ok(())
435}
436
437fn run_import(cfg: &Config, args: ImportArgs) -> Result<()> {
438 use agentstatedeveloper_core::schema::FeedbackEntry;
439 let raw = match args.input {
440 Some(ref path) => std::fs::read_to_string(path)?,
441 None => {
442 use std::io::Read;
443 let mut buf = String::new();
444 std::io::stdin().read_to_string(&mut buf)?;
445 buf
446 }
447 };
448 let incoming: Vec<FeedbackEntry> = serde_json::from_str(&raw)
449 .map_err(|e| anyhow::anyhow!("failed to parse feedback JSON: {e}"))?;
450
451 let engine = Engine::open_sqlite(&cfg.db_path)?;
452 let feedback_store = AsgFeedbackStore::from_engine(&engine);
453
454 let existing_ids: std::collections::HashSet<String> = if args.skip_existing || args.dry_run {
455 feedback_store
456 .list_all(&engine.ref_name)?
457 .into_iter()
458 .map(|e| e.entry_id)
459 .collect()
460 } else {
461 std::collections::HashSet::new()
462 };
463
464 let mut to_import: Vec<&FeedbackEntry> = Vec::new();
465 let mut skipped = 0usize;
466 for entry in &incoming {
467 if (args.skip_existing || args.dry_run) && existing_ids.contains(&entry.entry_id) {
468 skipped += 1;
469 continue;
470 }
471 to_import.push(entry);
472 }
473
474 let (useful, noisy, missing, wl) = {
475 let u = to_import
476 .iter()
477 .filter(|e| matches!(e.verdict, FeedbackVerdict::Useful))
478 .count();
479 let n = to_import
480 .iter()
481 .filter(|e| matches!(e.verdict, FeedbackVerdict::Noisy))
482 .count();
483 let m = to_import
484 .iter()
485 .filter(|e| matches!(e.verdict, FeedbackVerdict::Missing))
486 .count();
487 let w = to_import
488 .iter()
489 .filter(|e| matches!(e.verdict, FeedbackVerdict::WrongLayer))
490 .count();
491 (u, n, m, w)
492 };
493
494 if args.dry_run {
495 eprintln!(
496 "asd: [dry-run] would import {} entries, skip {} duplicates (useful={}, noisy={}, missing={}, wrong_layer={})",
497 to_import.len(),
498 skipped,
499 useful,
500 noisy,
501 missing,
502 wl
503 );
504 return Ok(());
505 }
506
507 for entry in &to_import {
508 feedback_store.record(&engine.ref_name, entry, &args.author)?;
509 }
510 eprintln!(
511 "asd: imported {} entries, skipped {} duplicates (useful={}, noisy={}, missing={}, wrong_layer={})",
512 to_import.len(),
513 skipped,
514 useful,
515 noisy,
516 missing,
517 wl
518 );
519 Ok(())
520}
521
522fn run_expire(cfg: &Config, args: ExpireArgs) -> Result<()> {
542 let engine = Engine::open_sqlite(&cfg.db_path)?;
543 let feedback_store = AsgFeedbackStore::from_engine(&engine);
544 let now = chrono::Utc::now();
545
546 let all = feedback_store.list_all(&engine.ref_name)?;
547
548 let wanted_query = args
551 .query
552 .as_ref()
553 .map(|q| q.to_lowercase().trim().to_string());
554
555 let selected: Vec<&FeedbackEntry> = match (&args.entry_id, &args.symbol, &wanted_query) {
556 (Some(id), _, _) => all.iter().filter(|e| &e.entry_id == id).collect(),
557 (_, Some(qname), _) => all.iter().filter(|e| &e.symbol_qname == qname).collect(),
558 (_, _, Some(q)) => all.iter().filter(|e| &e.query == q).collect(),
559 _ => bail!("give an entry id, or one of --symbol <qname> / --query <text>"),
560 };
561
562 if selected.is_empty() {
563 bail!(
564 "no feedback matched; `asd feedback list` shows {} recorded entr{}",
565 all.len(),
566 if all.len() == 1 { "y" } else { "ies" }
567 );
568 }
569
570 let (already, to_expire): (Vec<_>, Vec<_>) = selected
574 .into_iter()
575 .partition(|e| e.expires_at.is_some_and(|t| t <= now));
576
577 if args.dry_run {
578 let out = serde_json::json!({
579 "dry_run": true,
580 "would_expire": to_expire.iter().map(|e| summarize(e)).collect::<Vec<_>>(),
581 "already_expired": already.iter().map(|e| summarize(e)).collect::<Vec<_>>(),
582 });
583 println!("{}", serde_json::to_string_pretty(&out)?);
584 return Ok(());
585 }
586
587 let mut expired = Vec::new();
588 for entry in &to_expire {
589 let mut lapsed = (*entry).clone();
590 lapsed.expires_at = Some(now);
591 feedback_store.record(&engine.ref_name, &lapsed, &lapsed.author)?;
592 expired.push(summarize(&lapsed));
593 }
594
595 if args.json {
596 let out = serde_json::json!({
597 "expired": expired,
598 "already_expired": already.iter().map(|e| summarize(e)).collect::<Vec<_>>(),
599 });
600 println!("{}", serde_json::to_string_pretty(&out)?);
601 return Ok(());
602 }
603
604 for e in &to_expire {
605 println!(
606 "expired {} {:?} {} query={:?}",
607 e.entry_id, e.verdict, e.symbol_qname, e.query
608 );
609 }
610 for e in &already {
611 println!("already expired {} ({})", e.entry_id, e.symbol_qname);
612 }
613 if !expired.is_empty() {
614 println!(
615 "\n{} verdict{} will no longer influence ranking. They remain in \
616 `asd feedback list`, marked expired.",
617 expired.len(),
618 if expired.len() == 1 { "" } else { "s" }
619 );
620 }
621 Ok(())
622}
623
624fn summarize(e: &FeedbackEntry) -> serde_json::Value {
626 serde_json::json!({
627 "entry_id": e.entry_id,
628 "symbol_qname": e.symbol_qname,
629 "query": e.query,
630 "verdict": format!("{:?}", e.verdict),
631 "expires_at": e.expires_at,
632 })
633}
634
635fn run_withdraw(cfg: &Config, args: WithdrawArgs) -> Result<()> {
647 let engine = Engine::open_sqlite(&cfg.db_path)?;
648 let store = AsgFeedbackStore::from_engine(&engine);
649
650 let Some(entry) = store.withdraw(
651 &engine.ref_name,
652 &args.entry_id,
653 &args.by,
654 args.reason.as_deref(),
655 )?
656 else {
657 let n = store.list_all(&engine.ref_name)?.len();
658 bail!(
659 "no feedback entry {}; `asd feedback list` shows {} recorded entr{}",
660 args.entry_id,
661 n,
662 if n == 1 { "y" } else { "ies" }
663 );
664 };
665
666 if args.json {
667 println!("{}", serde_json::to_string_pretty(&entry)?);
668 return Ok(());
669 }
670 println!(
671 "withdrawn {} {:?} {} query={:?}",
672 entry.entry_id, entry.verdict, entry.symbol_qname, entry.query
673 );
674 if let Some(ref r) = entry.withdrawn_reason {
675 println!(" reason: {r}");
676 }
677 println!(
678 "\nIt no longer influences ranking. It remains in `asd feedback list`, \
679 marked withdrawn — it still explains a past ranking."
680 );
681 Ok(())
682}
683
684fn run_purge(cfg: &Config, args: PurgeArgs) -> Result<()> {
694 let engine = Engine::open_sqlite(&cfg.db_path)?;
695 let store = AsgFeedbackStore::from_engine(&engine);
696
697 let Some(target) = store
700 .list_all(&engine.ref_name)?
701 .into_iter()
702 .find(|e| e.entry_id == args.entry_id)
703 else {
704 bail!("no feedback entry {}", args.entry_id);
705 };
706
707 if !args.yes {
708 eprintln!(
709 "about to PERMANENTLY delete:\n \
710 {} {:?} {} query={:?}",
711 target.entry_id, target.verdict, target.symbol_qname, target.query
712 );
713 if let Some(ref n) = target.note {
714 eprintln!(" note: {n}");
715 }
716 bail!(
717 "refusing without --yes.\n\
718 This rewrites history in an append-only store. If the verdict was \
719 merely wrong, `asd feedback withdraw {}` retracts it while keeping \
720 the record — almost always what you want. Purge is for data that \
721 must not exist: test entries, or a secret pasted into a note.",
722 args.entry_id
723 );
724 }
725
726 let purged = store
727 .purge(&engine.ref_name, &args.entry_id)?
728 .ok_or_else(|| anyhow::anyhow!("no feedback entry {}", args.entry_id))?;
729
730 if args.json {
731 println!("{}", serde_json::json!({ "purged": true, "entry": purged }));
732 return Ok(());
733 }
734 println!(
735 "purged {} {:?} {} query={:?}",
736 purged.entry_id, purged.verdict, purged.symbol_qname, purged.query
737 );
738 println!("Gone from both the store and the search cache.");
739 Ok(())
740}