Refactor results to make check complexity easier to visualize in log

* Differentiate rule results from rule set results
* Implement function to parse all results for check and print and/or PLUS triggered state for all rule/sets in check
This commit is contained in:
FoxxMD
2021-06-21 17:10:24 -04:00
parent cced86381b
commit 7f29ade87b
10 changed files with 108 additions and 61 deletions

View File

@@ -1,11 +1,11 @@
import {RuleSet, IRuleSet, RuleSetJson, RuleSetObjectJson} from "../Rule/RuleSet";
import {IRule, Rule, RuleJSONConfig, RuleResult} from "../Rule";
import {IRule, isRuleSetResult, Rule, RuleJSONConfig, RuleResult, RuleSetResult} from "../Rule";
import Action, {ActionConfig, ActionJson} from "../Action";
import {Logger} from "winston";
import {Comment, Submission} from "snoowrap";
import {actionFactory} from "../Action/ActionFactory";
import {ruleFactory} from "../Rule/RuleFactory";
import {createAjvFactory, mergeArr, ruleNamesFromResults} from "../util";
import {createAjvFactory, mergeArr, resultsSummary, ruleNamesFromResults} from "../util";
import {
ChecksActivityState,
CommentState,
@@ -147,11 +147,12 @@ export class Check implements ICheck {
}
async runRules(item: Submission | Comment, existingResults: RuleResult[] = []): Promise<[boolean, RuleResult[]]> {
let allResults: RuleResult[] = [];
let allRuleResults: RuleResult[] = [];
let allResults: (RuleResult | RuleSetResult)[] = [];
const [itemPass, crit] = isItem(item, this.itemIs, this.logger);
if(!itemPass) {
this.logger.verbose(`❌ => Item did not pass 'itemIs' test`);
return [false, allResults];
return [false, allRuleResults];
}
let authorPass = null;
if (this.authorIs.include !== undefined && this.authorIs.include.length > 0) {
@@ -163,7 +164,7 @@ export class Check implements ICheck {
}
if(!authorPass) {
this.logger.verbose('❌ => Inclusive author criteria not matched');
return Promise.resolve([false, allResults]);
return Promise.resolve([false, allRuleResults]);
}
}
if (authorPass === null && this.authorIs.exclude !== undefined && this.authorIs.exclude.length > 0) {
@@ -175,45 +176,52 @@ export class Check implements ICheck {
}
if(!authorPass) {
this.logger.verbose('❌ => Exclusive author criteria not matched');
return Promise.resolve([false, allResults]);
return Promise.resolve([false, allRuleResults]);
}
}
if(this.rules.length === 0) {
this.logger.info(`✔️ => No rules to run, check auto-passes`);
return [true, allResults];
return [true, allRuleResults];
}
let runOne = false;
for (const r of this.rules) {
const combinedResults = [...existingResults, ...allResults];
//let results: RuleResult | RuleSetResult;
const combinedResults = [...existingResults, ...allRuleResults];
const [passed, results] = await r.run(item, combinedResults);
allResults = allResults.concat(results);
if(isRuleSetResult(results)) {
allRuleResults = allRuleResults.concat(results.results);
} else {
allRuleResults = allRuleResults.concat(results as RuleResult);
}
allResults.push(results);
if (passed === null) {
continue;
}
debugger;
runOne = true;
if (passed) {
if (this.condition === 'OR') {
this.logger.info(`✔️ => Rules (OR): ${ruleNamesFromResults(allResults)}`);
return [true, allResults];
this.logger.info(`✔️ => Rules: ${resultsSummary(allResults, this.condition)}`);
return [true, allRuleResults];
}
} else if (this.condition === 'AND') {
this.logger.info(`❌ => Rules (AND): ${ruleNamesFromResults(allResults)}`);
return [false, allResults];
this.logger.info(`❌ => Rules: ${resultsSummary(allResults, this.condition)}`);
return [false, allRuleResults];
}
}
if (!runOne) {
this.logger.verbose('❌ => All Rules skipped because of Author checks or itemIs tests');
return [false, allResults];
return [false, allRuleResults];
} else if(this.condition === 'OR') {
// if OR and did not return already then none passed
this.logger.verbose(`❌ => Rules (OR): ${ruleNamesFromResults(allResults)}`);
return [false, allResults];
this.logger.verbose(`❌ => Rules: ${resultsSummary(allResults, this.condition)}`);
return [false, allRuleResults];
}
// otherwise AND and did not return already so all passed
this.logger.info(`✔️ => Rules (AND) : ${ruleNamesFromResults(allResults)}`);
return [true, allResults];
this.logger.info(`✔️ => Rules: ${resultsSummary(allResults, this.condition)}`);
return [true, allRuleResults];
}
async runActions(item: Submission | Comment, ruleResults: RuleResult[]): Promise<Action[]> {

View File

@@ -53,21 +53,21 @@ export class AuthorRule extends Rule {
};
}
protected async process(item: Comment | Submission): Promise<[boolean, RuleResult[]]> {
protected async process(item: Comment | Submission): Promise<[boolean, RuleResult]> {
if (this.include.length > 0) {
for (const auth of this.include) {
if (await this.resources.testAuthorCriteria(item, auth)) {
return Promise.resolve([true, [this.getResult(true)]]);
return Promise.resolve([true, this.getResult(true)]);
}
}
return Promise.resolve([false, [this.getResult(false)]]);
return Promise.resolve([false, this.getResult(false)]);
}
for (const auth of this.exclude) {
if (await this.resources.testAuthorCriteria(item, auth, false)) {
return Promise.resolve([true, [this.getResult(true)]]);
return Promise.resolve([true, this.getResult(true)]);
}
}
return Promise.resolve([false, [this.getResult(false)]]);
return Promise.resolve([false, this.getResult(false)]);
}
}

View File

@@ -94,7 +94,7 @@ export class HistoryRule extends Rule {
}
}
protected async process(item: Submission): Promise<[boolean, RuleResult[]]> {
protected async process(item: Submission): Promise<[boolean, RuleResult]> {
// TODO reuse activities between ActivityCriteria to reduce api calls
let criteriaResults = [];
@@ -239,15 +239,15 @@ export class HistoryRule extends Rule {
const result = `${thresholdSummary} (${data.window})`;
this.logger.verbose(result);
return Promise.resolve([true, [this.getResult(true, {
return Promise.resolve([true, this.getResult(true, {
result,
data,
})]]);
})]);
}
}
return Promise.resolve([false, [this.getResult(false)]]);
return Promise.resolve([false, this.getResult(false)]);
}
}

View File

@@ -44,7 +44,7 @@ export class RecentActivityRule extends Rule {
}
}
async process(item: Submission | Comment): Promise<[boolean, RuleResult[]]> {
async process(item: Submission | Comment): Promise<[boolean, RuleResult]> {
let activities;
switch (this.lookAt) {
@@ -135,7 +135,7 @@ export class RecentActivityRule extends Rule {
}
const result = resultArr.join(' and ')
this.logger.verbose(result);
return Promise.resolve([true, [this.getResult(true, {
return Promise.resolve([true, this.getResult(true, {
result,
data: {
window: typeof this.window === 'number' ? `${activities.length} Items` : activityWindowText(viableActivity),
@@ -145,10 +145,10 @@ export class RecentActivityRule extends Rule {
subCount: data.totalSubredditsCount,
totalCount: data.totalCount
}
})]]);
})]);
}
return Promise.resolve([false, [this.getResult(false)]]);
return Promise.resolve([false, this.getResult(false)]);
}
}

View File

@@ -1,4 +1,4 @@
import {IRule, Triggerable, Rule, RuleJSONConfig, RuleResult} from "./index";
import {IRule, Triggerable, Rule, RuleJSONConfig, RuleResult, RuleSetResult} from "./index";
import {Comment, Submission} from "snoowrap";
import {ruleFactory} from "./RuleFactory";
import {createAjvFactory, mergeArr} from "../util";
@@ -8,7 +8,7 @@ import * as RuleSchema from '../Schema/Rule.json';
import Ajv from 'ajv';
import {RuleJson, RuleObjectJson} from "../Common/types";
export class RuleSet implements IRuleSet, Triggerable {
export class RuleSet implements IRuleSet {
rules: Rule[] = [];
condition: JoinOperands;
logger: Logger;
@@ -32,12 +32,12 @@ export class RuleSet implements IRuleSet, Triggerable {
}
}
async run(item: Comment | Submission, existingResults: RuleResult[] = []): Promise<[boolean, RuleResult[]]> {
async run(item: Comment | Submission, existingResults: RuleResult[] = []): Promise<[boolean, RuleSetResult]> {
let results: RuleResult[] = [];
let runOne = false;
for (const r of this.rules) {
const combinedResults = [...existingResults, ...results];
const [passed, [result]] = await r.run(item, combinedResults);
const [passed, result] = await r.run(item, combinedResults);
//results = results.concat(determineNewResults(combinedResults, result));
results.push(result);
// skip rule if author check failed
@@ -47,22 +47,30 @@ export class RuleSet implements IRuleSet, Triggerable {
runOne = true;
if (passed) {
if (this.condition === 'OR') {
return [true, results];
return [true, this.generateResultSet(true, results)];
}
} else if (this.condition === 'AND') {
return [false, results];
return [false, this.generateResultSet(false, results)];
}
}
// if no rules were run it's the same as if nothing was triggered
if (!runOne) {
return [false, results];
return [false, this.generateResultSet(false, results)];
}
if(this.condition === 'OR') {
// if OR and did not return already then none passed
return [false, results];
return [false, this.generateResultSet(false, results)];
}
// otherwise AND and did not return already so all passed
return [true, results];
return [true, this.generateResultSet(true, results)];
}
generateResultSet(triggered: boolean, results: RuleResult[]): RuleSetResult {
return {
results,
triggered,
condition: this.condition
};
}
}

View File

@@ -96,7 +96,7 @@ export class AttributionRule extends SubmissionRule {
}
}
protected async process(item: Submission): Promise<[boolean, RuleResult[]]> {
protected async process(item: Submission): Promise<[boolean, RuleResult]> {
const referenceUrl = await item.url;
if (referenceUrl === undefined && this.useSubmissionAsReference) {
throw new Error(`Cannot run Rule ${this.name} because submission is not a link`);
@@ -236,15 +236,15 @@ export class AttributionRule extends SubmissionRule {
const result = `${triggeredDomains.length} Attribution(s) met the threshold of ${threshold}, largest being ${largestCount} (${largestPercent}%) of ${activityTotal} Total -- window: ${data.window}`;
this.logger.verbose(result);
return Promise.resolve([true, [this.getResult(true, {
return Promise.resolve([true, this.getResult(true, {
result,
data,
})]]);
})]);
}
}
return Promise.resolve([false, [this.getResult(false)]]);
return Promise.resolve([false, this.getResult(false)]);
}
}

View File

@@ -81,11 +81,11 @@ export class RepeatActivityRule extends SubmissionRule {
}
}
async process(item: Submission): Promise<[boolean, RuleResult[]]> {
async process(item: Submission): Promise<[boolean, RuleResult]> {
const referenceUrl = await item.url;
if (referenceUrl === undefined && this.useSubmissionAsReference) {
this.logger.warn(`Rule not triggered because useSubmissionAsReference=true but submission is not a link`);
return Promise.resolve([false, [this.getResult(false)]]);
return Promise.resolve([false, this.getResult(false)]);
}
let activities: (Submission | Comment)[] = [];
@@ -193,7 +193,7 @@ export class RepeatActivityRule extends SubmissionRule {
const largestRepeat = triggeringSummaries.reduce((acc, summ) => Math.max(summ.largestTrigger, acc), 0);
const result = `${triggeringSummaries.length} of ${identifiersSummary.length} unique items repeated ${this.threshold} (threshold) times, largest repeat: ${largestRepeat}`;
this.logger.verbose(result);
return Promise.resolve([true, [this.getResult(true, {
return Promise.resolve([true, this.getResult(true, {
result,
data: {
window: typeof this.window === 'number' ? `${activities.length} Items` : activityWindowText(activities),
@@ -204,10 +204,10 @@ export class RepeatActivityRule extends SubmissionRule {
url: referenceUrl,
triggeringSummaries,
}
})]]);
})]);
}
return Promise.resolve([false, [this.getResult(false)]]);
return Promise.resolve([false, this.getResult(false)]);
}
}

View File

@@ -31,8 +31,18 @@ export interface RuleResult extends ResultContext {
triggered: (boolean | null)
}
export interface RuleSetResult {
results: RuleResult[],
condition: 'OR' | 'AND',
triggered: boolean
}
export const isRuleSetResult = (obj: any): obj is RuleSetResult => {
return typeof obj === 'object' && Array.isArray(obj.results) && obj.condition !== undefined && obj.triggered !== undefined;
}
export interface Triggerable {
run(item: Comment | Submission, existingResults: RuleResult[]): Promise<[(boolean | null), RuleResult[]]>;
run(item: Comment | Submission, existingResults: RuleResult[]): Promise<[(boolean | null), RuleResult?]>;
}
export abstract class Rule implements IRule, Triggerable {
@@ -66,16 +76,16 @@ export abstract class Rule implements IRule, Triggerable {
this.logger = logger.child({labels: ['Rule',`${this.getRuleUniqueName()}`]}, mergeArr);
}
async run(item: Comment | Submission, existingResults: RuleResult[] = []): Promise<[(boolean | null), RuleResult[]]> {
async run(item: Comment | Submission, existingResults: RuleResult[] = []): Promise<[(boolean | null), RuleResult]> {
const existingResult = findResultByPremise(this.getPremise(), existingResults);
if (existingResult) {
this.logger.debug(`Returning existing result of ${existingResult.triggered ? '✔️' : '❌'}`);
return Promise.resolve([existingResult.triggered, [{...existingResult, name: this.name}]]);
return Promise.resolve([existingResult.triggered, {...existingResult, name: this.name}]);
}
const [itemPass, crit] = isItem(item, this.itemIs, this.logger);
if(!itemPass) {
this.logger.verbose(`Item did not pass 'itemIs' test, rule running skipped`);
return Promise.resolve([null, [this.getResult(null, {result: `Item did not pass 'itemIs' test, rule running skipped`})]]);
this.logger.verbose(`(Skipped) Item did not pass 'itemIs' test`);
return Promise.resolve([null, this.getResult(null, {result: `Item did not pass 'itemIs' test`})]);
}
if (this.authorIs.include !== undefined && this.authorIs.include.length > 0) {
for (const auth of this.authorIs.include) {
@@ -83,8 +93,8 @@ export abstract class Rule implements IRule, Triggerable {
return this.process(item);
}
}
this.logger.verbose('Inclusive author criteria not matched, rule running skipped');
return Promise.resolve([null, [this.getResult(null, {result: 'Inclusive author criteria not matched, rule running skipped'})]]);
this.logger.verbose('(Skipped) Inclusive author criteria not matched');
return Promise.resolve([null, this.getResult(null, {result: 'Inclusive author criteria not matched'})]);
}
if (this.authorIs.exclude !== undefined && this.authorIs.exclude.length > 0) {
for (const auth of this.authorIs.exclude) {
@@ -92,13 +102,13 @@ export abstract class Rule implements IRule, Triggerable {
return this.process(item);
}
}
this.logger.verbose('Exclusive author criteria not matched, rule running skipped');
return Promise.resolve([null, [this.getResult(null, {result: 'Exclusive author criteria not matched, rule running skipped'})]]);
this.logger.verbose('(Skipped) Exclusive author criteria not matched');
return Promise.resolve([null, this.getResult(null, {result: 'Exclusive author criteria not matched'})]);
}
return this.process(item);
}
protected abstract process(item: Comment | Submission): Promise<[boolean, RuleResult[]]>;
protected abstract process(item: Comment | Submission): Promise<[boolean, RuleResult]>;
abstract getKind(): string;

View File

@@ -516,6 +516,8 @@ export const isItem = (item: Submission | Comment, stateCriteria: TypedActivityS
if (item[k] !== undefined) {
// @ts-ignore
if (item[k] !== crit[k]) {
// @ts-ignore
log.debug(`Failed: Expected => ${k}:${crit[k]} | Found => ${item[k]}`)
return [false, crit];
}
} else {
@@ -523,7 +525,7 @@ export const isItem = (item: Submission | Comment, stateCriteria: TypedActivityS
}
}
}
log.verbose(`itemIs passed: ${JSON.stringify(crit)}`);
log.debug(`Passed: ${JSON.stringify(crit)}`);
return [true, crit];
})() as [boolean, SubmissionState|CommentState|undefined];
if (pass) {

View File

@@ -1,7 +1,7 @@
import winston, {Logger} from "winston";
import jsonStringify from 'safe-stable-stringify';
import dayjs, {Dayjs, OpUnitType} from 'dayjs';
import {RulePremise, RuleResult} from "./Rule";
import {isRuleSetResult, RulePremise, RuleResult, RuleSetResult} from "./Rule";
import deepEqual from "fast-deep-equal";
import {Duration} from 'dayjs/plugin/duration.js';
import Ajv from "ajv";
@@ -203,6 +203,25 @@ export const ruleNamesFromResults = (results: RuleResult[]) => {
return results.map(x => x.name || x.premise.kind).join(' | ')
}
export const triggeredIndicator = (val: boolean | null): string => {
if(val === null) {
return '-';
}
return val ? '✔' : '❌';
}
export const resultsSummary = (results: (RuleResult|RuleSetResult)[], topLevelCondition: 'OR' | 'AND'): string => {
const parts: string[] = results.map((x) => {
if(isRuleSetResult(x)) {
return `${triggeredIndicator(x.triggered)} (${resultsSummary(x.results, x.condition)}${x.results.length === 1 ? ` [${x.condition}]` : ''})`;
}
const res = x as RuleResult;
return `${triggeredIndicator(x.triggered)} ${res.name}`;
});
return parts.join(` ${topLevelCondition} `)
//return results.map(x => x.name || x.premise.kind).join(' | ')
}
export const createAjvFactory = (logger: Logger) => {
return new Ajv({logger: logger, verbose: true, strict: "log", allowUnionTypes: true});
}