analytics.ts 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136
  1. import { Command } from "commander";
  2. import { StateManager } from "@actalk/inkos-core";
  3. import { loadConfig, findProjectRoot, resolveBookId, log, logError } from "../utils.js";
  4. interface AnalyticsData {
  5. readonly bookId: string;
  6. readonly totalChapters: number;
  7. readonly totalWords: number;
  8. readonly avgWordsPerChapter: number;
  9. readonly auditPassRate: number;
  10. readonly topIssueCategories: ReadonlyArray<{ readonly category: string; readonly count: number }>;
  11. readonly chaptersWithMostIssues: ReadonlyArray<{ readonly chapter: number; readonly issueCount: number }>;
  12. readonly statusDistribution: Record<string, number>;
  13. }
  14. export function computeAnalytics(
  15. bookId: string,
  16. chapters: ReadonlyArray<{
  17. readonly number: number;
  18. readonly status: string;
  19. readonly wordCount: number;
  20. readonly auditIssues: ReadonlyArray<string>;
  21. }>,
  22. ): AnalyticsData {
  23. const totalChapters = chapters.length;
  24. const totalWords = chapters.reduce((sum, ch) => sum + ch.wordCount, 0);
  25. const avgWordsPerChapter = totalChapters > 0 ? Math.round(totalWords / totalChapters) : 0;
  26. // Audit pass rate: chapters that went to ready-for-review or approved on first try
  27. const passedStatuses = new Set(["ready-for-review", "approved", "published"]);
  28. const auditedChapters = chapters.filter(
  29. (ch) => ch.status !== "drafted" && ch.status !== "drafting" && ch.status !== "card-generated",
  30. );
  31. const passedChapters = auditedChapters.filter((ch) => passedStatuses.has(ch.status));
  32. const auditPassRate = auditedChapters.length > 0
  33. ? Math.round((passedChapters.length / auditedChapters.length) * 100)
  34. : 100;
  35. // Issue category extraction
  36. const categoryCounts = new Map<string, number>();
  37. for (const ch of chapters) {
  38. for (const issue of ch.auditIssues) {
  39. // Issue format: "[severity] description"
  40. // Try to extract category from the description
  41. const catMatch = issue.match(/\[(?:critical|warning|info)\]\s*(.+?)[::]/);
  42. const category = catMatch?.[1] ?? "未分类";
  43. categoryCounts.set(category, (categoryCounts.get(category) ?? 0) + 1);
  44. }
  45. }
  46. const topIssueCategories = [...categoryCounts.entries()]
  47. .sort((a, b) => b[1] - a[1])
  48. .slice(0, 10)
  49. .map(([category, count]) => ({ category, count }));
  50. // Chapters with most issues
  51. const chaptersWithMostIssues = [...chapters]
  52. .filter((ch) => ch.auditIssues.length > 0)
  53. .sort((a, b) => b.auditIssues.length - a.auditIssues.length)
  54. .slice(0, 5)
  55. .map((ch) => ({ chapter: ch.number, issueCount: ch.auditIssues.length }));
  56. // Status distribution
  57. const statusDistribution: Record<string, number> = {};
  58. for (const ch of chapters) {
  59. statusDistribution[ch.status] = (statusDistribution[ch.status] ?? 0) + 1;
  60. }
  61. return {
  62. bookId,
  63. totalChapters,
  64. totalWords,
  65. avgWordsPerChapter,
  66. auditPassRate,
  67. topIssueCategories,
  68. chaptersWithMostIssues,
  69. statusDistribution,
  70. };
  71. }
  72. export const analyticsCommand = new Command("analytics")
  73. .description("Show analytics for a book")
  74. .argument("[book-id]", "Book ID (auto-detected if only one book)")
  75. .option("--json", "Output JSON")
  76. .action(async (bookIdArg: string | undefined, opts) => {
  77. try {
  78. await loadConfig();
  79. const root = findProjectRoot();
  80. const bookId = await resolveBookId(bookIdArg, root);
  81. const state = new StateManager(root);
  82. const chapters = await state.loadChapterIndex(bookId);
  83. const analytics = computeAnalytics(bookId, chapters);
  84. if (opts.json) {
  85. log(JSON.stringify(analytics, null, 2));
  86. } else {
  87. log(`Analytics for "${bookId}":`);
  88. log("");
  89. log(` Total chapters: ${analytics.totalChapters}`);
  90. log(` Total words: ${analytics.totalWords.toLocaleString()}`);
  91. log(` Avg words/chapter: ${analytics.avgWordsPerChapter.toLocaleString()}`);
  92. log(` Audit pass rate: ${analytics.auditPassRate}%`);
  93. log("");
  94. if (Object.keys(analytics.statusDistribution).length > 0) {
  95. log(" Status distribution:");
  96. for (const [status, count] of Object.entries(analytics.statusDistribution)) {
  97. log(` ${status}: ${count}`);
  98. }
  99. log("");
  100. }
  101. if (analytics.topIssueCategories.length > 0) {
  102. log(" Most common issue categories:");
  103. for (const { category, count } of analytics.topIssueCategories) {
  104. log(` ${category}: ${count}`);
  105. }
  106. log("");
  107. }
  108. if (analytics.chaptersWithMostIssues.length > 0) {
  109. log(" Chapters with most issues:");
  110. for (const { chapter, issueCount } of analytics.chaptersWithMostIssues) {
  111. log(` Ch.${chapter}: ${issueCount} issues`);
  112. }
  113. }
  114. }
  115. } catch (e) {
  116. if (opts.json) {
  117. log(JSON.stringify({ error: String(e) }));
  118. } else {
  119. logError(`Analytics failed: ${e}`);
  120. }
  121. process.exit(1);
  122. }
  123. });